Lakehouse lab
Lesson 1
Object storage, file systems, and block storage
Why the lake lives in buckets and keys, what S3 promises and refuses, and how that differs from the disk and folders you already know.
beginner40 minRun on your machine
What you will be able to do
- Explain block, file, and object storage and name one real system for each
- Read and write objects in RustFS with plain HTTP and the S3 verbs
- Predict which file-system habits break on object storage and why
Why this matters for data work
Every lakehouse, every cloud warehouse’s external tables, and every “just put the Parquet in S3” pipeline sits on object storage. It looks like a file system in a browser console — folders, sizes, dates — and that resemblance is a trap. Object storage has no folders, no append, no rename, and no locks. Half of the design of Apache Iceberg (the next lesson) exists to work around those facts.
You have been using the other two models since the Linux module: a file system (~/dcubes, ls, >>) on top of block storage (the disk). Learn where each one stops so you can predict what a data engine may and may not do on the lake.
Concepts
Three storage models
Object storage File system Block storage
------------------ ------------------- -------------------
bucket + key directories + inodes device + block number
"lakehouse" / /home/sam/dcubes/ /dev/sda1, block 4096
"landing/orders.csv" lakehouse-lab/orders.csv
GET / PUT / DELETE open, seek, read, read block, write block
LIST over HTTP write, append, rename
RustFS, S3, GCS, ext4, NTFS, APFS, SSD, EBS volume,
Azure Blob, MinIO NFS, HDFS Docker volume’s disk
Block storage is the raw disk: a numbered list of fixed-size blocks. It knows nothing about files. A volume attaches to one machine at a time, and something must format it with a file system before it is useful. Postgres data directories, Docker volumes, and your laptop’s SSD are block storage underneath.
A file system turns blocks into a tree: directories, file names, permissions, timestamps. It gives programs the POSIX verbs — open, seek, read, write, append, rename, flock. Rename is atomic and cheap: the file’s data does not move, only its directory entry. Append is cheap too: write more blocks and update the length.
Object storage is a flat key-value store reached over HTTP. A bucket holds objects; each object is a key (a string), a blob of bytes, and a small map of metadata (size, ETag, content type). The verbs are PUT whole object, GET whole object or a byte range, HEAD for metadata, DELETE, and LIST keys by prefix. That is the entire API. Amazon S3 defined it, and RustFS, MinIO, Ceph, GCS, and Azure Blob all speak it or something close.
Side by side
| Block | File system | Object storage | |
|---|---|---|---|
| Address | device + block number | path | bucket + key |
| Namespace | none | hierarchical tree | flat; / is just a character |
| Change part of it | yes, any block | yes, seek and write / append | no — replace the whole object |
| Rename | n/a | atomic, cheap | none — copy, then delete |
| Locks | by the file system | flock, leases |
none |
| Shared by | one machine | one machine, or a few over NFS | anything with HTTP and credentials |
| Size limit | the volume | the volume | effectively none per bucket |
| Latency | microseconds | microseconds | tens of milliseconds per request |
| Cost per TB | highest | high | lowest, plus per-request fees |
| Durability | one disk unless you mirror | one disk unless you mirror | replicated or erasure-coded by design |
| Data work | databases, OS, scratch | your laptop, HDFS, shared drives | lakes, backups, Parquet, model files |
There are no folders
landing/orders.csv is one key. The / is part of the name. When the RustFS console draws a landing folder, it is asking the server to LIST with prefix= and delimiter=/, and the server groups keys by the text before the next / — a common prefix. Consequences:
- An empty folder cannot exist, unless a tool uploads a zero-byte object named
landing/as a marker. - Deleting the last object under a prefix makes the “folder” vanish.
- “Renaming a folder” with a thousand objects is a thousand copies and a thousand deletes, and it is not atomic — a reader in the middle sees half old, half new.
- Listing is paginated (1,000 keys per page) and slow compared with
ls. A table with 100,000 files needs 100 round trips just to find out which files exist.
Objects are immutable
There is no append and no in-place edit. To change one byte you PUT the whole object again under the same key; the store swaps the old blob for the new one. Versioning, when enabled, keeps the old one under a version id. Concurrent PUTs to the same key do not merge — last writer wins.
This is why a data engine never “opens the table file and adds rows.” It writes new files and then changes a pointer somewhere. Where that pointer lives, and how to swap it safely, is the whole reason table formats exist.
It is HTTP
Every call is a signed HTTP request. The signature (AWS Signature Version 4) proves you hold the access key and secret; without it the server answers 403. GET accepts a Range header, so a reader can fetch the last few kilobytes of a Parquet file (its footer, where the column index lives) and then only the columns it needs. That one feature makes columnar formats on object storage fast enough to be a warehouse.
Consistency
S3 has been strongly consistent since December 2020: after a PUT returns, every GET and LIST sees it. RustFS and MinIO match that. What object storage still does not give you:
- A way to change two objects atomically.
- An atomic rename, so “write to
_tmpthen rename over the real file” — the classic file-system trick — does not exist. - Locks, so two writers cannot coordinate through the store itself.
Hive-style tables were designed on HDFS, which is a file system and does rename. Moving them to S3 broke that assumption: partial writes became visible, and “which files are in this table” meant a slow, racy LIST. Iceberg fixes this by keeping the file list in metadata and doing its one atomic swap in a catalog, not in the bucket.
Where each model shows up in the lab
| Piece of the lab | Storage model |
|---|---|
dcubes-lakehouse_rustfs-data Docker volume |
Block, formatted by your host’s file system — RustFS stores its objects on a file system; the object API is a layer on top |
~/dcubes/lakehouse-lab/samples/orders.csv |
File system |
s3://lakehouse/landing/orders.csv |
Object |
s3://lakehouse/tables/<uuid>/data/*.parquet |
Objects Spark wrote; Trino reads them by range |
Airflow’s Postgres volume (profile orchestrate) |
Block + file system, as every database needs |
Azure Data Lake Storage Gen2 is the odd one out: object storage with an optional hierarchical namespace that gives real directories and atomic rename back. It exists precisely because the gaps above hurt.
Practice on your machine
The lab must be up from Getting Started. Nothing here needs a new tool: curl can sign S3 requests itself (--aws-sigv4, curl 7.75 or newer — Ubuntu 22.04, WSL, and macOS all qualify).
cd ~/dcubes/lakehouse-lab
docker compose ps rustfs
curl --version | head -1
Every request below uses the lab credentials. Put them in two variables in the terminal you are using (repeat this in any new terminal):
S3='--aws-sigv4 aws:amz:us-east-1:s3 --user dcubesadmin:dcubes-lab-not-for-prod'
B=http://127.0.0.1:19100/lakehouse
1. It is a web server
Ask for an object without signing:
curl -s -i http://127.0.0.1:19100/lakehouse/landing/orders.csv | head -5
HTTP/1.1 403 Forbidden
content-type: application/xml
The object exists. The server refused because the request carries no signature. Now HEAD it with credentials:
curl -s -I $S3 $B/landing/orders.csv
HTTP/1.1 200 OK
accept-ranges: bytes
content-length: 32764
content-type: text/csv
etag: "41fe67e402256d4152aab310041f723b"
last-modified: Fri, 18 Sep 2026 01:33:56 GMT
That header block is the object’s metadata. Compare with stat samples/orders.csv on the file system: same idea, but there is no owner, no mode bits, no inode — just size, type, a content hash, and a time.
2. List by prefix, not by folder
Ask the bucket for its “top level”:
curl -s $S3 "$B?list-type=2&delimiter=/" | grep -o '<Prefix>[^<]*</Prefix>'
<Prefix>landing/</Prefix>
<Prefix>tables/</Prefix>
Those are common prefixes, computed on the fly. Drop the delimiter and every key comes back flat:
curl -s $S3 "$B?list-type=2" | grep -o '<Key>[^<]*</Key>'
<Key>landing/orders.csv</Key>
<Key>tables/3889a3bf-…/data/00000-0-….parquet</Key>
<Key>tables/3889a3bf-…/metadata/….avro</Key>
Data files and Iceberg metadata files are ordinary objects with long keys. There is no directory object anywhere in that list.
3. Read only the bytes you need
Fetch the first 120 bytes of the CSV — the header line — without downloading 32 KB:
curl -s -r 0-120 $S3 $B/landing/orders.csv
order_id,order_ts,store_id,customer_id,sku,product_name,category,quantity,unit_price,line_total,payment_method,status
That -r is the Range header. Trino uses the same trick to read a Parquet footer and then only the columns your query names.
4. Write, overwrite, and notice there is no append
Create an object from stdin. No folder was created first:
printf 'hello object storage\n' | curl -s -i -X PUT --data-binary @- $S3 $B/scratch/notes.txt | head -2
HTTP/1.1 200 OK
etag: "6950c8884b3336d1f7dae1fe475b857e"
Read it back:
curl -s $S3 $B/scratch/notes.txt
Now try to “append a line” the file-system way. There is no verb for it — the only option is to PUT the whole thing again:
printf 'second version\n' | curl -s -X PUT --data-binary @- $S3 $B/scratch/notes.txt
curl -s $S3 $B/scratch/notes.txt
second version
The first line is gone. The object was replaced, not extended. On your laptop >> would have kept both lines.
5. Delete the last object and the folder disappears
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE $S3 $B/scratch/notes.txt
curl -s $S3 "$B?list-type=2&prefix=scratch/" | grep -o '<KeyCount>[^<]*</KeyCount>'
204
<KeyCount>0</KeyCount>
scratch/ never existed as a thing. It appeared when a key started with it and vanished when none did.
6. Under the object store is a file system
RustFS itself keeps its objects on ordinary disk. Look at the volume Docker gave it:
docker volume inspect dcubes-lakehouse_rustfs-data --format '{{.Mountpoint}}'
df -h /var/lib/docker 2>/dev/null || df -h /
On WSL and macOS the Docker VM owns that path, so df reports the VM’s disk — still a block device with a file system on it. Object storage is not a different kind of disk. It is a different contract offered over the network.
What you should see
403unsigned,200signed, on the same URL.- Two common prefixes at the top of the bucket, and a flat list of keys without the delimiter.
- A 120-byte range read that returns exactly the CSV header.
- A
PUTthat replacesnotes.txtentirely;204on delete;KeyCount0afterwards.
Common mistakes
- Treating a bucket like a mounted drive. Tools that “mount S3” (s3fs, mountpoint-s3) translate every
writeinto a whole-objectPUT. Fine for reading, dangerous for anything that seeks or appends. - Editing an object in place. Log appenders, SQLite files, and anything that opens a file
r+do not work on object storage. Write new objects; let a table format track them. - Relying on
LISTto know what is in a table. Slow, paginated, and racy while a writer is mid-job. This is the Hive problem the next lesson fixes. - Using rename as a commit. There is no rename. “Write to
_tmp, then move” is two copies and a delete, and readers can see the middle state. - Confusing the console’s folders with real ones. Deleting the last file under a prefix removes the “folder”; creating a folder in the console uploads an empty marker object.
- Leaving the lab keys in a shell history you share. They are lab-only; still, never paste them anywhere but
~/dcubes/lakehouse-lab.