Lakehouse Digest
ISSUE 014 · OCTOBER 2, 2026

COPY INTO won't reload your fixed file: the filename-tracked idempotency trap

COPY INTO is retryable and idempotent — files already loaded are skipped on subsequent runs, even if they were modified since. Force re-reads the file, but it doesn't dedupe: the production repair lands a versioned file, stages it, and merges by business key.

THE THREE-PART READ

One path, one load — content be damned.

COPY INTO tracks loaded files by path. A file modified after loading is still “already loaded.” Scope loads by filename, disable tracking only deliberately, and never expect an append-only loader to dedupe.

TIP

The silent skip is documented

The official docs state files already loaded are skipped “even if the files have been modified since they were loaded.” An in-place fix followed by a re-run loads zero rows with no error.

Understand the trap →
IMPLEMENTATION

Version, stage, then merge

Land the correction under a new filename, load it into staging, then MERGE on order_id. The target changes atomically.

Run the repair →
DEV Q&A

Force is not a dedupe switch

force disables idempotency, so the file is appended again. Corrupt files are the exception: they are never tracked, so a fixed file re-loads normally.

Get the answers →

Last verified against official Databricks documentation: October 2, 2026. Correction history: first publication.

COPY INTO trusts the path, not the content

COPY INTO loads data from a file location into a Delta table. It is retryable and idempotent: files in the source location that have already been loaded are skipped on subsequent runs — and the official docs make the uncomfortable part explicit: “Files in the source location that have already been loaded are skipped. This is true even if the files have been modified since they were loaded.”

The rule: the tracker records which paths were loaded. It never compares content. Overwrite a landed file in place and COPY INTO will never notice.

This is the right behavior for scheduled batch loads — a crashed job can re-run safely — but it is the wrong mental model when your workflow is “fix the file, re-run the load.”

Setup

dbutils.fs.put(
  "/tmp/copyinto_demo/orders_001.csv",
  "order_id,total\n1,42.50\n2,19.99\n",
  overwrite=True
)
CREATE TABLE IF NOT EXISTS bronze_orders (
  order_id INT,
  total DOUBLE
);

COPY INTO bronze_orders
FROM 'dbfs:/tmp/copyinto_demo'
FILEFORMAT = CSV
FORMAT_OPTIONS ('header' = 'true', 'inferSchema' = 'true');

Before

bronze_orders holds: (1, 42.50) and (2, 19.99).

Step 1 — the silent skip

The vendor fixes a typo in place — same filename, 19.99 → 29.99 — and the load re-runs.

dbutils.fs.put(
  "/tmp/copyinto_demo/orders_001.csv",
  "order_id,total\n1,42.50\n2,29.99\n",
  overwrite=True
)
COPY INTO bronze_orders
FROM 'dbfs:/tmp/copyinto_demo'
FILEFORMAT = CSV
FORMAT_OPTIONS ('header' = 'true', 'inferSchema' = 'true');
Expected result: 0 rows loaded. The table still holds (1, 42.50) and (2, 19.99). The re-run was a silent skip — the path was already tracked.
“Files in the source location that have already been loaded are skipped. This is true even if the files have been modified since they were loaded.”

Step 2 — force re-reads, but it doesn't dedupe

The documented escape hatch is COPY_OPTIONS ('force' = 'true'): “If set to true, idempotency is disabled and files are loaded regardless of whether they've been loaded before.” FILES and PATTERN only scope which files are considered; they do not bypass the already-loaded check. Only COPY_OPTIONS ('force' = 'true') disables load tracking.

COPY INTO bronze_orders
FROM 'dbfs:/tmp/copyinto_demo'
FILEFORMAT = CSV
FILES = ('orders_001.csv')
FORMAT_OPTIONS ('header' = 'true', 'inferSchema' = 'true')
COPY_OPTIONS ('force' = 'true');
Expected result: the table now holds (1, 42.50), (1, 42.50), (2, 19.99), (2, 29.99) — four rows. Force disabled tracking, so the corrected file was appended next to the stale rows. FILES scopes the run to one file (limit: 1000 files, cannot combine with PATTERN).

Demo only — DELETE + force is non-atomic

This sequence cleans up the forced-load demonstration, but it is not a production repair. A failure after DELETE and before COPY INTO leaves the target missing rows.

DELETE FROM bronze_orders
WHERE order_id IN (1, 2);

COPY INTO bronze_orders
FROM 'dbfs:/tmp/copyinto_demo'
FILEFORMAT = CSV
FILES = ('orders_001.csv')
FORMAT_OPTIONS ('header' = 'true', 'inferSchema' = 'true')
COPY_OPTIONS ('force' = 'true');
Demo result: (1, 42.50) and (2, 29.99) — but the two-statement repair is non-atomic and not safe for production.

Step 3 — the production repair

Land the corrected drop under a new versioned filename, load only that new file into a staging table, then atomically MERGE into bronze_orders on the business key.

dbutils.fs.put(
  "/tmp/copyinto_demo/orders_001_v2.csv",
  "order_id,total\n1,42.50\n2,29.99\n",
  overwrite=True
)
CREATE OR REPLACE TABLE bronze_orders_repair_stage (
  order_id INT,
  total DOUBLE
);

COPY INTO bronze_orders_repair_stage
FROM 'dbfs:/tmp/copyinto_demo'
FILEFORMAT = CSV
FILES = ('orders_001_v2.csv')
FORMAT_OPTIONS ('header' = 'true', 'inferSchema' = 'true');

MERGE INTO bronze_orders AS target
USING bronze_orders_repair_stage AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN
  UPDATE SET target.total = source.total
WHEN NOT MATCHED THEN
  INSERT (order_id, total) VALUES (source.order_id, source.total);
Expected result: bronze_orders holds (1, 42.50) and (2, 29.99) — corrected rows only. The new path is treated as a new file, so no force is needed; the target upsert is atomic.

Validate per key

Validate the repaired production target by business key, not only by row count.

SELECT order_id, COUNT(*)
FROM bronze_orders
GROUP BY order_id
HAVING COUNT(*) > 1;
-- expect zero rows

The per-key uniqueness assertion is the real invariant; the before/after row listings above are the expected output.

The production pattern: version the corrected drop, load it into staging, then merge on the business key. The new path avoids force; the atomic upsert avoids a delete/reload gap.

FILES accepts up to 1000 file names and cannot be combined with PATTERN. For directories with a very large number of files, the Databricks docs recommend Auto Loader instead.

  1. Never overwrite landed files in place — land each drop under a unique versioned name.
  2. First-load in VALIDATE mode: COPY INTO ... VALIDATE ALL (or VALIDATE n ROWS) validates without writing.
  3. Correcting a loaded file in production: land a new versioned file, COPY INTO a staging table, then MERGE on the business key (atomic). DELETE + force is non-atomic — demo only.
  4. Corrupt files aren't tracked — fix and re-run normally, no force needed.
  5. FILES caps at 1000 files and can't combine with PATTERN; very large directories belong on Auto Loader.
  6. COPY INTO is append-only — enforce business-key uniqueness in silver (MERGE), not at load.

Use this when

One-time or scheduled batch loads of moderate file counts; retry-safe scheduled ingestion where re-runs must be safe; SQL-only ingestion pipelines.

Avoid this when

Continuous arrival or very large directories (the docs recommend Auto Loader); in-place file overwrites as a correction workflow — COPY INTO keys on the path, not the content; relying on COPY INTO for row-level dedupe — it is append-only, so dedupe in silver.

Why did COPY INTO load 0 rows with no error?
Every file in the scope was already loaded before. COPY INTO skips already-loaded files even when they were modified after loading, so a re-run after an in-place fix loads nothing. Land corrections under new versioned filenames. FILES and PATTERN only scope candidate files; they do not bypass load tracking.
I used force=true and now every row is duplicated — why?
force disables idempotency: the file is loaded regardless of whether it was loaded before, so the corrected rows are appended next to the stale ones. Force re-reads; it does not dedupe. DELETE followed by force is non-atomic and demo-only; in production, land a versioned file, load it into staging, and MERGE by business key.
Do I need force to re-load a file I fixed after a corruption failure?
No. Corrupt files aren't tracked by COPY INTO, so a fixed file loads normally on the next run. Use VALIDATE mode to find which files were corrupt before re-running.
Assumptions: Databricks Runtime 11.3 LTS+; an existing Delta target table (COPY INTO requires one); CSV sources; classic single-user compute with DBFS for the demo file landing — on Unity Catalog-restricted compute, swap dbfs:/tmp/... for a Unity Catalog Volume path.
Provenance: Last verified against official Databricks docs: 2026-10-02. Correction history: first publication — no corrections.
RECOMMENDED LEARNING

Make retries safe — and corrections deliberate.

Start with the idempotency trap, then strengthen the platform and workflow skills behind the repair.

GO DEEPER

The COPY INTO idempotency trap

Why modified files are skipped, how force re-reads without deduping, and the versioned-drop pattern.

Run the repair →
BUILD FOR PRODUCTION

Data engineering workflows

Pipelines, orchestration, and streaming practice that turn this issue's tactics into repeatable habits.

Browse DataCamp data engineering →

Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.