S3 to Databricks: Load Files as Apache Iceberg
Files land in S3 every hour or every day: exports from an application, drops from a partner, logs from a job. S3 to Databricks usually means an Auto Loader job on a cluster, and tables written by that job.
This guide takes a different route. OLake Go reads the files itself and turns each folder into an Apache Iceberg table in Unity Catalog. Every later run syncs only the files that are new.
The tables land as Unity Catalog managed Iceberg tables, with the files in your own cloud storage. Nothing else is in the path: no Databricks job, no cluster during the load. DuckDB and Spark read the same tables through the same catalog.
Key takeaways
- OLake Go turns S3 folders into Databricks Iceberg tables: the first sync reads every file under the prefix, and every later sync reads only files newer than the last run, by their modification time.
- The tables are Unity Catalog managed Iceberg tables. You query them from the Databricks SQL editor or a notebook right away, with no external table and no refresh.
- The same tables, written once by OLake Go, are also queryable from DuckDB and Spark through Unity Catalog. In the run on this page all three engines returned identical rows.
The short answer. To load S3 files into Databricks as Iceberg, run OLake Go with the bucket and prefix as the source and Unity Catalog as the destination. Put each stream in Full Refresh + Incremental mode with _last_modified_time as the cursor.
The first sync reads every file; every later sync reads only the files added since. Databricks, DuckDB and Spark then query the same Iceberg tables, with no second copy.
How S3 files reach Databricks here
The first sync lists everything under the prefix and makes one stream per first-level folder. Then it reads every file in parallel: that is the historical load. At the end it saves the newest LastModified time it saw per stream.
Every later sync lists the prefix again and reads only the files whose LastModified time is newer than the saved cursor. Rows are appended; a file never changes a row that an earlier file wrote. Each sync ends with one commit to Unity Catalog, which Databricks sees on its next query.
One thing to keep an eye on: the cursor is the object's modification time, not anything inside the file. Uploading a corrected copy of yesterday's file gives it today's LastModified time, and every row in it is appended a second time. Add files, never rewrite them, and keep an id column so a query can pick the latest version of a row.
What you need
- An S3 bucket, or an S3-compatible store, with files in folders under one prefix: CSV, JSON, Parquet or XML, plain or gzip. Amazon S3, MinIO and LocalStack all work; the run on this page used MinIO in Docker with two CSV folders.
- Read-only access to the bucket:
s3:ListBucketands3:GetObject, through a role on AWS or static keys on MinIO. - A Databricks workspace with Unity Catalog, a catalog with managed storage, and an admin who can turn on external data access and grant
EXTERNAL USE SCHEMA. The run on this page used a workspace on Google Cloud. - OLake UI from the Docker Compose install with the OLake Go S3 source. The run on this page used v1.1.0.
- Docker, and about 30 minutes.
Step 1: Prepare the S3 bucket
Most readers point OLake Go at files they already have. If you want to follow along with the exact data from this page, upload two CSV files shaped like these first:
The two sample files used on this page
Each stream is a folder under data/, with one dated subfolder per upload. The header row names the columns, and id identifies the row so a later file can correct it.
id,name,email,city,tier,created_at,updated_at
1,Aarav Mehta,aarav@example.com,Mumbai,gold,2026-10-29T15:12:59Z,2026-10-29T15:12:59Z
2,Priya Sharma,priya@example.com,Bengaluru,silver,2026-10-29T15:12:59Z,2026-10-29T15:12:59Z
id,customer_id,product,amount,status,created_at,updated_at
1,2,Headphones,99.19,shipped,2026-10-29T15:12:59Z,2026-10-29T15:12:59Z
2,3,Monitor,178.38,delivered,2026-10-29T15:12:59Z,2026-10-29T15:12:59Z
aws s3 sync ./stage s3://shop/data/
# Then add whatever you like. This page started with 8 customers and 40 orders.
There is nothing to switch on in S3. OLake Go lists the objects under a prefix, groups them into streams by their first folder, and remembers the newest LastModified time it has read. Four things decide whether that works for your bucket.
| Requirement | Value | Why it matters |
|---|---|---|
| Folder layout | <prefix>/<stream>/.../file | The first folder after the prefix is the stream name and becomes the table. Files sitting directly under the prefix belong to no stream. |
| File format | CSV, JSON (lines or array), Parquet or XML, plain or gzip | One format per source. Mixed buckets need one source per format, or a file pattern that picks one. |
| Read-only access | s3:ListBucket on the bucket, s3:GetObject on its objects | Listing finds the files and tracks their LastModified time; GetObject reads them. |
| Files that are added, not rewritten | New file per batch | Incremental syncs read files newer than the saved cursor. A rewritten file gets a new LastModified time and is read again in full. |
This is the layout the run on this page used, with one CSV file per day per stream:
s3://shop/data/
├── customers/
│ └── 2026-10-29/customers.csv
└── orders/
└── 2026-10-29/orders.csv
On AWS, attach a policy with the two read actions to the user or role OLake Go runs as. On MinIO or another S3-compatible store, a user with read access to the bucket is enough, and the endpoint goes into the source form.
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::shop"] },
{ "Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::shop/*"] }
]
}
Leave the access key fields empty on AWS to use the instance role, task role or environment credentials instead of static keys. Static keys are required for MinIO and LocalStack.
OLake Go reads a file when its LastModified time is newer than the cursor saved after the last sync. Uploading a corrected copy of an old file therefore re-reads the whole file and appends every row again. Add new files for new data; keep old files as they are.
Step 2: Set up Unity Catalog in Databricks
Databricks reads Iceberg tables through Unity Catalog, the catalog built into every Databricks workspace. OLake Go writes straight into it, so the tables become Unity Catalog managed Iceberg tables. They show up in Catalog Explorer and the SQL editor the moment a sync commits.
The files sit in the catalog's managed storage, which is backed by an external location in your own cloud account. DuckDB and Spark reach the same tables through the same Unity Catalog endpoint.
| Catalog | OLake Go writes | Databricks reads | Verified on this page |
|---|---|---|---|
| Unity Catalog | Yes | Yes: native: Unity Catalog managed Iceberg tables | Yes, end to end |
- Unity Catalog
Unity Catalog is the catalog built into every Databricks workspace. OLake Go writes to it through Unity Catalog's Iceberg REST (representational state transfer) endpoint at https://<workspace-url>/api/2.1/unity-catalog/iceberg-rest, and the tables it creates are Unity Catalog managed Iceberg tables. Databricks allows writes through this endpoint for managed Iceberg tables only (Databricks: access tables from Iceberg clients, checked 2026-10-30).
Four things must be in place in the workspace, once:
| Setting | Where | Why it matters |
|---|---|---|
| External data access on the metastore | Catalog, the gear icon, Metastore, then External data access | Without it the Iceberg REST endpoint refuses every client that is not Databricks itself. |
| A catalog with managed storage | Catalog, Create catalog, with a storage location backed by an external location | OLake Go creates one schema per job inside it. The catalog name is the warehouse value every client uses. |
EXTERNAL USE SCHEMA for the writing principal | On the catalog, so it covers the schemas OLake Go creates | Unity Catalog checks this privilege, on top of the usual ones, before it serves a table to an outside engine. |
| A personal access token or a service principal | Settings, Developer, Access tokens | OLake Go sends it as a bearer token on every catalog call. |
-- The catalog OLake Go writes into. Its managed storage holds the Iceberg files.
CREATE CATALOG IF NOT EXISTS lakehouse MANAGED LOCATION '<external location URL>/lakehouse';
-- What the writing principal needs: browse and create schemas, then read and write tables in them
GRANT USE CATALOG, CREATE SCHEMA ON CATALOG lakehouse TO `olake@example.com`;
GRANT USE SCHEMA, CREATE TABLE, SELECT, MODIFY ON CATALOG lakehouse TO `olake@example.com`;
GRANT EXTERNAL USE SCHEMA ON CATALOG lakehouse TO `olake@example.com`;
Granting on the catalog makes the privileges inherit to every schema OLake Go creates later. EXTERNAL USE SCHEMA is not part of ALL PRIVILEGES, and only the catalog owner can grant it (Databricks: Unity Catalog privileges, checked 2026-10-30). A service principal with OAuth machine-to-machine credentials is the better choice in production. The other authentication methods are on the Unity Catalog destination page.
Unity Catalog accepts updates to a managed Iceberg table only as copy-on-write files or as Iceberg v3 deletion vectors. It rejects equality delete files with Can not create a Path from a null string [ErrorCode: 2015]. Set Databricks as a target query engine on the job, and OLake Go writes deletion vectors from the first sync.
Step 3: Connect S3 to OLake Go
- OLake UI
- OLake CLI
- In the OLake UI, open Sources and click Create Source. Pick S3 as the connector, give the source a name and keep the OLake Go version on the latest release.
- The Enable CDC toggle under Change data capture (CDC) reads not supported for S3 and stays off. Object storage has no change log; the job's Full Refresh + Incremental mode on the file modification time is what carries new data.
- Under Connection details, fill in the S3 Bucket Name, the AWS Region, and the Path Prefix that holds the stream folders. On AWS, leave Access Key ID and Secret Access Key empty to use the role OLake Go runs with; on MinIO or LocalStack fill them in and set Custom Endpoint to the service URL.
- Pick the File Format. For CSV, check the delimiter, the header toggle and the quote character; for XML, set the row identifier. File Pattern is an optional regular expression on the object key, useful when a prefix mixes formats.
- Click Set up source. OLake Go calls
HeadBucketwith the credentials; a 403 or 404 here means the key, the bucket name or the endpoint is wrong.
Here is the source form from the run on this page. MinIO ran in another container, so the custom endpoint is http://host.docker.internal:9000. The bucket is shop, the prefix data/, and the format CSV with a header row.
Set up source calls HeadBucket before anything is saved, so a wrong key or bucket name fails here rather than on the first sync.
The full field reference, including JSON, Parquet and XML options, is on the S3 connector page.
Create source.json in your config folder. bucket_name, region, path_prefix and file_format are the fields that matter; endpoint and the static keys are for S3-compatible stores:
{
"bucket_name": "source-data",
"region": "us-east-1",
"path_prefix": "csv/",
"access_key_id": "minioadmin",
"secret_access_key": "minioadmin",
"endpoint": "http://localhost:9000",
"file_format": "csv",
"max_threads": 5,
"retry_count": 3,
"csv": {
"has_header": true,
"delimiter": ",",
"skip_rows": 0,
"quote_character": "\""
}
}
CSV Configuration Fields:
| Field | Description | Default |
|---|---|---|
delimiter | Field separator character | "," |
has_header | Whether CSV files contain a header row | true |
skip_rows | Number of rows to skip at the beginning of CSV files | 0 |
quote_character | Quote character for CSV files | "\"" |
Note: Gzip compression is automatically detected for .csv.gz files.
Then confirm OLake Go can reach the bucket:
docker run --pull=always \
-v "$PWD/config:/mnt/config" \
olakego/source-s3:latest \
check --config /mnt/config/source.json
A working setup returns "status": "SUCCEEDED". A wrong key returns failed to access bucket <name>: operation error S3: HeadBucket, https response error StatusCode: 403; a wrong bucket name the same message with StatusCode: 404. Every field is described on the S3 connector page.
Step 4: Configure the Iceberg destination
- Unity Catalog
- OLake UI
- OLake CLI
- Open Destinations and click Create Destination.
- Pick Apache Iceberg as the connector and Unity as the catalog type.
- Choose Personal Access Token as the authentication type and fill in the fields below. The catalog name from the setup goes into the catalog field.
- Click Create. OLake Go calls the Unity Catalog endpoint with the token and saves the destination.

The run on this page wrote into a catalog called datazip, with a personal access token that expires after seven days. The snippets below use lakehouse; replace it with your catalog name.
Unity Configuration Parameters
| Parameter | Sample Value | Description |
|---|---|---|
REST Catalog URLrequired | https://1234567890123456.1.gcp.databricks.com/ | Databricks workspace URL with Unity Catalog REST API endpoint. Use your actual workspace URL. |
Authentication Typerequired | Personal Access Token | Authentication method OLake Go uses for Unity Catalog REST API requests. Set to match your workspace's authentication configuration. |
Unity Catalog Namerequired | workspace | Name of the catalog in Unity. |
Tokenrequired | dapi123456789... | Databricks Personal Access Token |
| Catalog Name | olake_iceberg | Name of the Iceberg catalog OLake Go registers tables under. Defaults to olake_iceberg if left empty. |
| Enable Arrow Writes | false | Writes data and delete files using Apache Arrow based writer and registers them in Iceberg. |
Create destination.json next to source.json:
{
"type": "ICEBERG",
"writer": {
"catalog_type": "unity",
"catalog_name": "olake_iceberg",
"rest_catalog_url": "https://<workspace-url>/api/2.1/unity-catalog/iceberg-rest",
"iceberg_s3_path": "lakehouse",
"rest_auth_type": "personal access token",
"token": "<personal access token>",
"arrow_writes": false
}
}
iceberg_s3_path holds the Unity Catalog catalog name, not a bucket path: Unity Catalog decides where the files go.
Unity Configuration Parameters
| Parameter | Sample Value | Description |
|---|---|---|
catalog_typerequired | unity | Defines the catalog type used by the writer. |
rest_catalog_urlrequired | https://1234567890123456.1.gcp.databricks.com/ | Databricks workspace URL with Unity Catalog REST API endpoint. Use your actual workspace URL. |
iceberg_s3_pathrequired | workspace | Name of the catalog in Unity. |
rest_auth_typerequired | Personal Access Token | Authentication method OLake Go uses for Unity Catalog REST API requests. Set to match your workspace's authentication configuration. |
tokenrequired | dapi123456789... | Databricks Personal Access Token |
| catalog_name | olake_iceberg | Name of the Iceberg catalog OLake Go registers tables under. Defaults to olake_iceberg if left empty. |
| arrow_writes | false | Writes data and delete files using Apache Arrow based writer and registers them in Iceberg. |
Step 5: Create the job and run the first sync
S3 streams differ from database streams in one way on the Streams page. The ingestion mode is Append and cannot be changed, because a file carries no key to match an earlier row by. Set the sync mode to Full Refresh + Incremental and leave the cursor on _last_modified_time, which OLake Go adds to every stream.
- OLake UI
- OLake CLI
- Open Jobs and click Create Job. Give it a name and a schedule. Every Day at a fixed hour, or a cron expression, both work; the schedule is how often the change stream is drained, not how often a full copy runs.
- Select the source and the destination you created.
- Under Advanced Settings, set Target Query Engines to Databricks, plus DuckDB and Apache Spark if they will read the tables too. OLake Go then resolves an Iceberg delete format every one of those engines can read, so you never pick one by hand.
- Click Next. OLake Go discovers the folders and opens the Streams page.
- Tick the folders to replicate. For each one, set the sync mode to Full Refresh + Incremental with
_last_modified_timeas the cursor column and the ingestion mode to Append. - Click Create Job, then open the job's menu and choose Sync Now for the first run.
Three target query engines were set on this job. With Append mode there are no delete files to agree on, so the choice only records which engines read the tables.

Discover the folders, choose the streams, then sync:
docker run --pull=always \
-v "$PWD/config:/mnt/config" \
olakego/source-s3:latest \
discover --config /mnt/config/source.json
discover writes two files: available_streams.json (every folder it found, do not edit) and selected_streams.json (the folders you want and their settings). In selected_streams.json, set sync_mode to {props.cliSyncMode ?? 'cdc'} for each folder:
{
"selected_streams": {
"s3": [
{
"stream_name": "customers",
"sync_mode": "incremental",
"cursor_field": "_last_modified_time",
"normalization": true,
"append_mode": true
}
]
}
}
Append mode writes data files only, so no delete format or index directory is involved. The first sync runs without a state file; OLake Go writes one at the end of the run:
docker run --pull=always \
-v "$PWD/config:/mnt/config" \
olakego/source-s3:latest \
sync \
--config /mnt/config/source.json \
--selected-streams /mnt/config/selected_streams.json \
--available-streams /mnt/config/available_streams.json \
--destination /mnt/config/destination.json
Every later run adds --state /mnt/config/state.json to the same command, which is how OLake Go knows to replay only the changes since the last run. The full selected_streams.json reference is on the Docker CLI page; the index directory is described under index DB setup.
On this page the first run read the 8 customer and 40 order rows (48 records, 2.61 KB). It finished in 23 seconds, container start included. OLake Go names the Unity Catalog schema after the job, the bucket and the namespace, so s3_to_databricks on bucket shop produced s3_to_databricks_shop_s3.
Step 6: Query the tables in Databricks
- Unity Catalog
There is no registration step. The schema and tables OLake Go committed through Unity Catalog are managed Iceberg tables in the catalog you configured, and the Databricks SQL editor or a notebook reads them by their three-part name.
-- What OLake Go created (schema = <job name>_<source database>[_<source schema>])
SHOW TABLES IN lakehouse.s3_to_databricks_shop_s3;
SELECT id, name, city, tier
FROM lakehouse.s3_to_databricks_shop_s3.customers
ORDER BY id;
SELECT status, count(*) AS n, round(sum(amount), 2) AS total
FROM lakehouse.s3_to_databricks_shop_s3.orders
GROUP BY status ORDER BY status;
Any SQL warehouse or cluster on a recent Databricks Runtime reads them; a 2X-Small serverless warehouse is enough for checks. DESCRIBE EXTENDED on a table shows the Iceberg provider and the deletion vectors in its properties.
OLake Go adds four columns to every table: _olake_id (a hash of the primary key), _op_type, _olake_timestamp and _cdc_timestamp. _op_type is r for a row from the first snapshot, c for an insert, u for an update and d for a delete. Filter them out of dashboards, or use them to see when a row last changed.
Files only append, so the queries above return every row from every file: 12 customer rows and 48 order rows after the second sync. The query that most readers want is the latest version of each row. QUALIFY does that in one statement, ordering by the file time OLake Go stored in _last_modified_time:
SELECT CAST(id AS INT) AS id, name, city, tier
FROM lakehouse.s3_to_databricks_shop_s3.customers
QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY _last_modified_time DESC) = 1
ORDER BY id;
SELECT status, count(*) AS n, round(sum(amount), 2) AS total
FROM (
SELECT * FROM lakehouse.s3_to_databricks_shop_s3.orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY _last_modified_time DESC) = 1
)
GROUP BY status ORDER BY status;
It returned 10 customers, and 45 orders worth 25579.19 in total, on the 2X-Small serverless warehouse.
Step 7: Add files to the bucket and sync again
To see the incremental path work, the run on this page uploaded one more file per folder after the first sync. Each carries new rows and corrected copies of existing rows, the way a daily export does:
data/customers/2026-10-29/customers-changes.csv 4 rows: ids 9 and 10 are new, ids 3 and 8 are corrections
data/orders/2026-10-29/orders-changes.csv 8 rows: ids 41 to 45 are new, ids 1, 4 and 7 now read "delivered"
The second sync listed both folders, skipped the two files it had already read, and read the two new ones. That was 12 records (686 bytes) in 17 seconds end to end. OLake Go's commit report shows operation=append with one data file per table, 4 rows for customers and 8 for orders, and no delete files.

Databricks shows the result on the next query, with no refresh. The corrected rows exist twice, and _op_type tells them apart: r for the 8 rows of the first full read, u for the 4 that arrived through the incremental sync. The latest-row query from step 6 returns Liam Carter as gold and Zara Ali in Abu Dhabi.
Schema changes. When a new column appears in a later file, OLake Go adds it to the Iceberg table on the next sync. Databricks shows it with nulls for older rows.
A column whose inferred type changes in an incompatible way fails the sync rather than corrupt the table. Full rules are on the schema evolution page.
Query the same tables from DuckDB and Spark
Nothing is exported, copied or refreshed. Both engines read the files OLake Go wrote, through the same Unity Catalog endpoint, with storage credentials vended by Databricks. The same QUALIFY pattern works in DuckDB; Spark uses a ROW_NUMBER() subquery.
- DuckDB
- Apache Spark
DuckDB attaches the Unity Catalog catalog as an Iceberg REST catalog with the same token. With vended_credentials, Unity Catalog hands DuckDB short-lived storage credentials per table, so no cloud keys are configured on the DuckDB side.
INSTALL iceberg; LOAD iceberg;
INSTALL httpfs; LOAD httpfs;
CREATE SECRET unity (TYPE iceberg, TOKEN '<personal access token>');
ATTACH 'lakehouse' AS uc (
TYPE iceberg,
ENDPOINT 'https://<workspace-url>/api/2.1/unity-catalog/iceberg-rest',
SECRET unity,
ACCESS_DELEGATION_MODE 'vended_credentials'
);
SELECT id, name, city, tier
FROM uc.s3_to_databricks_shop_s3.customers
ORDER BY id;
DuckDB reads Iceberg v3 deletion vectors from duckdb-iceberg 1.5.3, so the rows match Databricks after every sync. The principal behind the token needs EXTERNAL USE SCHEMA, the same as OLake Go.
DuckDB 1.5.5 returned the same 12 customer rows as Databricks, and the same 10 with QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY _last_modified_time DESC) = 1.
Open source Spark reaches Unity Catalog as a plain Iceberg REST catalog. The warehouse is the catalog name, the token is a personal access token, and the X-Iceberg-Access-Delegation header asks Unity Catalog for short-lived storage credentials.
spark-sql \
--packages org.apache.iceberg:iceberg-spark-runtime-4.0_2.13:1.10.0,org.apache.iceberg:iceberg-gcp-bundle:1.10.0 \
--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
--conf spark.sql.catalog.uc=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.uc.type=rest \
--conf spark.sql.catalog.uc.uri=https://<workspace-url>/api/2.1/unity-catalog/iceberg-rest \
--conf spark.sql.catalog.uc.warehouse=lakehouse \
--conf spark.sql.catalog.uc.token=<personal access token> \
--conf spark.sql.catalog.uc.header.X-Iceberg-Access-Delegation=vended-credentials
SELECT id, name, city, tier FROM uc.s3_to_databricks_shop_s3.customers ORDER BY id;
Iceberg 1.8 or later reads the v3 deletion vectors OLake Go writes for Databricks; this page was tested with 1.10.0 on Spark 4.0.1. Swap iceberg-gcp-bundle for iceberg-aws-bundle or iceberg-azure-bundle to match the cloud the workspace runs on.
Spark 4.0.1 with Iceberg 1.10.0 returned the same 10 customers and the same orders roll-up as Databricks and DuckDB in the run on this page. It used ROW_NUMBER() OVER (PARTITION BY id ORDER BY _last_modified_time DESC) in a subquery.
An orders roll-up over the latest row per id matched the files in all three engines. It gave 16 delivered orders for 8659.68, 18 placed for 10522.26, 11 shipped for 6397.25.
Data types: CSV to Iceberg to Databricks
A CSV file has no types, so OLake Go infers them from a sample of rows at discover time and maps them to Iceberg types. Databricks then maps Iceberg types to its own. Parquet files skip the inference and keep their own types.
| In the CSV | Iceberg (written by OLake Go) | Databricks shows | Notes |
|---|---|---|---|
Whole numbers (id, customer_id) | double | DOUBLE | In this run the integer columns were inferred as floating point. Cast with CAST(id AS INT) in queries, or use Parquet when the type matters. |
Decimals (amount) | double | DOUBLE | The orders.amount sums on this page matched the files to the cent in every engine; round at query time when you need exact decimal arithmetic. |
ISO 8601 timestamps (created_at) | timestamptz (UTC) | TIMESTAMP | Strings that parse as timestamps are stored as timestamps. |
| Other text | string | STRING | |
_last_modified_time | string | STRING | The object's LastModified time in ISO 8601, so it sorts correctly as text. Added to every row by OLake Go. |
Types on the left come from the S3 connector reference; the Databricks side comes from DESCRIBE on the tables of this run. JSON files map integers to bigint; Parquet keeps int, bigint, date and timestamp as they are.
Cost and maintenance
- Storage is your cloud bucket behind the catalog's managed location. You pay your cloud's storage rates for the Parquet data files and deletion vectors.
- Compute. OLake Go runs wherever you run Docker and does not use a Databricks warehouse or cluster to write. Databricks compute is spent only when you query; the checks on this page ran on a 2X-Small serverless SQL warehouse.
- Maintenance. Each sync adds a small data file, plus a deletion vector when rows change. Plan compaction from the writer side: OLake Fusion does this for the catalogs OLake Go writes to, and Spark's
rewrite_data_filesworks through the same Unity Catalog endpoint. - Token rotation. A personal access token expires on the date set when it was created, and an expired token fails the next sync at the first catalog call. Use a service principal with OAuth machine-to-machine credentials for production jobs.
- Listing calls. Every sync lists the whole prefix to find new files. A prefix with hundreds of thousands of objects makes that the slowest part of the run; keep the prefix to the folders OLake Go needs, or move processed files out.
Troubleshooting
1. The source test is refused
failed to access bucket shop: operation error S3: HeadBucket, https response error StatusCode: 403, api error Forbidden: Forbidden
The key pair cannot read the bucket. On AWS, check that the policy from step 1 is attached to the user or role, and that the bucket policy does not deny it.
On MinIO, the access key or secret is wrong. A StatusCode: 404 with NotFound instead means the bucket name is wrong or the endpoint points at another store.
2. Discovery finds nothing
Discovered 0 files in 0 streams (after filtering)
Stream grouping enabled at level 1 (first folder after path_prefix)
FATAL no streams found in connector
Either the prefix holds no files, or the files sit directly under it with no folder to name the stream. Move them into a folder per table (data/customers/...), check that Path Prefix ends with /, and loosen File Pattern if you set one.
3. Rows appear twice after a sync
A file was rewritten in place, or a corrected copy was uploaded with the same rows. Its new LastModified time made OLake Go read it again, and Append mode added every row a second time. Keep the latest-row query from step 6 for reads, and change the upstream process to add files rather than replace them.
4. The destination test fails with a permission error
Three settings cover nearly every failure. External data access may be off on the metastore, or the principal may lack EXTERNAL USE SCHEMA on the catalog. The third is a wrong catalog name in the destination.
ALL PRIVILEGES does not include EXTERNAL USE SCHEMA, so a catalog owner still has to grant it explicitly. Run the grants from step 2 again for the principal that owns the token.
How this compares with other ways to load S3 files into Databricks
| Method | Freshness | Updates and deletes | Where the data lands | What you run |
|---|---|---|---|---|
| Auto Loader (checked Oct 2026) | Minutes, or continuous while the stream runs | Appends new files; merge with your own code | Tables written by a Databricks job or pipeline | A Databricks job or pipeline running on Databricks compute |
| Lakeflow Connect | S3 is not among its managed file connectors, which list Google Drive and SharePoint (checked Oct 2026) | Not applicable | Not applicable | Not applicable |
| OLake Go to Iceberg on Unity Catalog (this guide) | On a schedule down to one minute | No; files are appended, with _last_modified_time for latest-row queries | Apache Iceberg tables that Databricks, DuckDB, Spark and Trino all read | One Docker Compose stack, open source; your storage plus your own compute |
Auto Loader is the simpler choice when Databricks is the only reader and a job on Databricks compute is fine. OLake Go keeps the load off Databricks compute, and it keeps the pipeline the same when a database or Kafka source is added beside the files.
When not to use this
- Rows must be updated or deleted at write time. Files have no key, so S3 streams only append. Use a database or Kafka source for that, or merge downstream.
- The files are rewritten in place. Every rewrite is read again in full. Change the producer to add files, or accept duplicates and query the latest row per key.
- Files are not CSV, JSON, Parquet or XML. Fixed-width, Excel and Avro files are not read.
FAQs
How do I load files from S3 into Databricks as Iceberg tables without Auto Loader?
Run OLake Go (open source, Apache 2.0) with the bucket as the source and Unity Catalog's Iceberg REST endpoint as the destination. Each folder under the prefix becomes a Unity Catalog managed Iceberg table, the first sync reads every file, and every later sync reads only files newer than the last run. No Databricks job or cluster runs during the load.
Does OLake Go re-read the whole bucket on every sync?
No. OLake Go saves the newest LastModified time it has read per stream and, on the next sync, lists the prefix and reads only files modified after it. The second sync on this page read only the two new files, 12 records in total. A file that is rewritten in place gets a new LastModified time and is read again in full.
Can OLake Go update or delete rows from S3 files?
No. Files carry no primary key or change log, so S3 streams run in Append mode only: every row in every new file becomes a row in the table. When a later file corrects a row, both versions exist. Keep the latest with a QUALIFY ROW_NUMBER() query over the id and _last_modified_time columns, or merge downstream.
Which file formats does OLake Go read from S3?
CSV (with delimiter, header and quote options), JSON as line-delimited or array files, Parquet, and XML with a row identifier, each plain or gzip-compressed. One source handles one format; a file pattern narrows the keys when a prefix holds more than one. Parquet keeps its own types; CSV types are inferred from a sample of rows.
Does this work with MinIO, LocalStack or another S3-compatible store?
Yes. Set the custom endpoint on the source and provide static keys. The run on this page used MinIO in Docker with the endpoint http://host.docker.internal:9000. On AWS S3 itself the endpoint stays empty and the keys can be left out to use an instance or task role.
How are the Databricks tables named for an S3 source?
After the folder. The first folder under the path prefix is the stream, so data/customers/ becomes a table called customers. The schema defaults to the job name, the bucket name and the namespace s3; the job s3_to_databricks on bucket shop produced s3_to_databricks_shop_s3. The Iceberg DB field on the Streams page changes it before the first sync.
Related guides
Deeper reading on this site: