Skip to main content
Databricks beginner Lesson 4 of 10

Ingesting Files with Auto Loader and COPY INTO

Incremental file ingestion that remembers what it loaded, infers a schema, evolves when a column appears, and rescues the data that does not fit.

Both tools solve the same problem — load files that keep arriving, exactly once — and both remember what they have already read. The difference is scale and how much they do about a schema that changes.

COPY INTO

create table if not exists bookshop.raw.orders_landed (
    order_id    bigint,
    customer_id bigint,
    ordered_at  date,
    status      string,
    amount      decimal(10,2)
);

copy into bookshop.raw.orders_landed
from '/Volumes/bookshop/raw/landing/'
fileformat = csv
format_options ('header' = 'true', 'inferSchema' = 'false')
copy_options ('mergeSchema' = 'false');
num_affected_rows  num_inserted_rows  num_skipped_corrupt_files
-----------------  -----------------  -------------------------
             9812               9812                          0

Run it again with no new files:

num_affected_rows  num_inserted_rows  num_skipped_corrupt_files
-----------------  -----------------  -------------------------
                0                  0                          0

Zero. COPY INTO records the files it has loaded against the target table, so re-running is safe — the same idempotence dbt’s incremental models and Snowflake’s COPY INTO give you, and the reason a retried job does not duplicate rows.

Add a file to the volume and run once more:

num_affected_rows  num_inserted_rows  num_skipped_corrupt_files
-----------------  -----------------  -------------------------
             4120               4120                          0

Only the new file. That makes COPY INTO a complete incremental loader in one statement, which is often all a daily batch needs.

Auto Loader

For continuous arrival, or millions of files, the streaming source scales better:

from pyspark.sql.functions import current_timestamp, input_file_name

(spark.readStream
    .format("cloudFiles")
    .option("cloudFiles.format", "csv")
    .option("cloudFiles.schemaLocation", "/Volumes/bookshop/raw/_schemas/orders")
    .option("header", "true")
    .load("/Volumes/bookshop/raw/landing/")
    .withColumn("_ingested_at", current_timestamp())
    .withColumn("_source_file", input_file_name())
 .writeStream
    .option("checkpointLocation", "/Volumes/bookshop/raw/_checkpoints/orders")
    .trigger(availableNow=True)
    .toTable("bookshop.raw.orders_autoloaded"))
{
  "id": "8f2c1a44-8e21-4b0e-9a3c-1d84f0b27a51",
  "runId": "3d9c4b21-77a1-4e02-b8f1-5c2e91a4d883",
  "batchId": 0,
  "numInputRows": 13932,
  "inputRowsPerSecond": 0.0,
  "processedRowsPerSecond": 4204.11,
  "sources": [{
    "description": "CloudFilesSource[/Volumes/bookshop/raw/landing/]",
    "numFilesProcessed": 2,
    "numBytesProcessed": 242704
  }]
}

Two options carry the weight here:

  • schemaLocation — where the inferred schema is stored between runs, so column order and types stay stable and evolution can be detected.
  • checkpointLocation — what has been processed. Treat it as production state: deleting it reprocesses every file in the directory, which on an append target means duplicates.

trigger(availableNow=True) processes everything waiting and stops, which is how you get incremental semantics on a schedule without paying for an always-on stream. Drop it for a continuous stream, or use processingTime for micro-batches.

spark.table("bookshop.raw.orders_autoloaded").select("order_id", "amount", "_source_file").show(3, truncate=60)
+--------+------+------------------------------------------------------------+
|order_id|amount|                                                _source_file|
+--------+------+------------------------------------------------------------+
|    1001| 25.50|/Volumes/bookshop/raw/landing/orders_2026_01.csv             |
|    1002| 12.00|/Volumes/bookshop/raw/landing/orders_2026_01.csv             |
|    1003| 40.00|/Volumes/bookshop/raw/landing/orders_2026_01.csv             |
+--------+------+------------------------------------------------------------+
only showing top 3 rows

Recording the source file costs one column and answers “where did this row come from” for the life of the table.

Rescued data

Point Auto Loader at a file where amount is sometimes text:

(spark.readStream.format("cloudFiles")
    .option("cloudFiles.format", "csv")
    .option("cloudFiles.schemaLocation", "/Volumes/bookshop/raw/_schemas/orders")
    .option("cloudFiles.schemaHints", "order_id bigint, amount decimal(10,2)")
    .option("header", "true")
    .load("/Volumes/bookshop/raw/landing/")
 .writeStream
    .option("checkpointLocation", "/Volumes/bookshop/raw/_checkpoints/orders")
    .trigger(availableNow=True)
    .toTable("bookshop.raw.orders_autoloaded"))
select order_id, amount, _rescued_data
from bookshop.raw.orders_autoloaded
where _rescued_data is not null
limit 3;
order_id  amount  _rescued_data
--------  ------  --------------------------------------------------------------
    1412    NULL  {"amount":"n/a","_file_path":"/Volumes/.../orders_2026_01.csv"}
    3980    NULL  {"amount":"n/a","_file_path":"/Volumes/.../orders_2026_01.csv"}
    4771    NULL  {"amount":"","_file_path":"/Volumes/.../orders_2026_01.csv"}

The rows loaded, the bad values were preserved as JSON, and nothing failed. This is the behaviour to want in a bronze layer: never lose a record, never let a bad value masquerade as a good one. Alert on _rescued_data is not null and the malformed rows become a ticket rather than a silent gap.

schemaHints pins the types you care about and lets Auto Loader infer the rest — better than full inference, which can decide a column is a string because of one bad file, and better than a full schema, which you then have to maintain.

Schema evolution

An upstream team adds a channel column mid-month:

StreamingQueryException: Encountered unknown field(s) during parsing: {"channel":"web"}

A schema mismatch detected when writing to the Delta table.
The stream has been stopped so the new schema can be picked up.
Restart the stream to continue.

The stream stops on purpose. cloudFiles.schemaEvolutionMode decides what happens:

ModeBehaviour
addNewColumns (default)fail the stream, record the new schema, succeed on restart
rescuekeep going, put unknown fields in _rescued_data
failOnNewColumnsfail and do not update the schema — you edit it
noneignore new columns entirely

The default surprises people, but it is the right trade: a job orchestrated with retries restarts, picks up the new column, and continues, while the failure is visible in the run history. rescue is the choice when the stream must never stop and you will reconcile later.

    .option("cloudFiles.schemaEvolutionMode", "rescue")
select count(*) as rescued from bookshop.raw.orders_autoloaded where _rescued_data is not null;
rescued
-------
   4120

Finding files at scale

By default Auto Loader lists the directory. Past a few hundred thousand files that gets slow, so switch to file notifications — the cloud provider tells Databricks about each new object:

    .option("cloudFiles.useNotifications", "true")

Directory listing is simpler and needs no extra cloud permissions; notifications need rights to create a queue and subscription but scale to millions of files. Start with listing and switch when listing time shows up in your batch duration.

Two more options worth knowing:

    .option("cloudFiles.maxFilesPerTrigger", "1000")     # bound each batch
    .option("cloudFiles.includeExistingFiles", "false")  # only files arriving from now on

Watching a stream

for q in spark.streams.active:
    print(q.name, q.status["message"], q.lastProgress["numInputRows"])
orders_ingest Waiting for data to arrive 0
describe history bookshop.raw.orders_autoloaded limit 3;
version  operation           operationMetrics
-------  ------------------  --------------------------------------------
     12  STREAMING UPDATE    {numOutputRows: 4120, numAddedFiles: 3}
     11  STREAMING UPDATE    {numOutputRows: 9812, numAddedFiles: 7}
     10  CREATE TABLE        {}

Each micro-batch is a Delta commit, so the ingestion history is the table history — including time travel back to before a bad batch.

Practice

1. Run COPY INTO twice against the same directory.
num_inserted_rows
-----------------
             9812

num_inserted_rows
-----------------
                0

The second run loads nothing. If you genuinely need to reload, copy_options ('force' = 'true') overrides it — and duplicates every row, so truncate first.

2. Delete an Auto Loader checkpoint and rerun.
"numFilesProcessed": 9,
"numInputRows": 13932

Every file again, and 13,932 duplicate rows in an append target. The checkpoint is the only record of what has been processed — back it up with the same care as the data, and never point two streams at one checkpoint.

3. Put a bad value in a numeric column and inspect _rescued_data.
order_id  amount  _rescued_data
--------  ------  --------------------------------
    1412    NULL  {"amount":"n/a","_file_path":...}

The row survived with the bad value intact. Contrast with mode=DROPMALFORMED, which loses the record entirely, and FAILFAST, which stops the pipeline — rescued data is the option that lets you fix it tomorrow without losing anything today.

4. Add a column to an incoming file and restart the stream.
-- first run
StreamingQueryException: Encountered unknown field(s) during parsing: {"channel":"web"}
-- after restart
"numInputRows": 4120

Failure then success, with the new column now in the table. Because the schema location was updated by the failed run, the restart needs no code change — which is why this works cleanly under a job with retries configured.

Next: the medallion architecture — bronze, silver and gold, and what belongs in each.

Frequently Asked Questions

What is Auto Loader in Databricks?
A structured streaming source, `cloudFiles`, that incrementally processes new files arriving in a storage location. It tracks processed files in a checkpoint using RocksDB, so it scales to millions of files without listing the directory each run.
When should I use COPY INTO instead of Auto Loader?
`COPY INTO` suits a small, bounded set of files loaded on a schedule — thousands, not millions — and is a single SQL statement. Auto Loader suits continuous or high-volume arrival and gives schema evolution and rescued data. Both are idempotent.
What is the rescued data column?
`_rescued_data`, a JSON column holding any field that did not match the table's schema — a wrong type, or a column that was not expected. It means a malformed record is never silently dropped: the row loads and the problem value is preserved for inspection.
Does Auto Loader reprocess files if a job restarts?
No. The checkpoint records which files have been processed, so a restart resumes where it stopped. Deleting the checkpoint makes it reprocess everything, which is why the checkpoint location must be treated as state, not as a scratch directory.