Course · 11

Where do files live, and how do we find things fast?

This page covers three jobs. Keeping big files safe and cheap. Finding words inside millions of documents. Answering big "sum up everything" questions. Every number here was checked against official docs on 2026-10-25.

TL;DR

Put bytes in object storage, put facts about them in a database, and copy text into a search index that you rebuild from the database.

  • Object storage (S3) is a giant key → file map over HTTPS. Clients upload straight to it with a pre-signed URL; your server never touches the bytes.
  • Big files go up in parts: S3 takes up to 10,000 parts of 5 MiB–5 GiB. Dropbox cuts files into 4 MB blocks named by their SHA-256 hash, so a block already stored is never sent again.
  • LIKE '%cat%' reads every row. Search engines flip the data around: an inverted index maps each word to the documents that hold it.
  • Elasticsearch shows new documents after a refresh, every 1 s by default, but only on indices searched in the last 30 s.
  • Keep search in sync by reading the database's change log (CDC), not by writing to two places from your app.
5GB
biggest single S3 PUT
10,000
max parts in one multipart upload
7days
longest a pre-signed URL can live (SigV4, IAM user)
1s
default Elasticsearch refresh interval

—Words you need

Each hard word once, in plain words glossary

Block storage
A raw disk. Data is numbered chunks; the OS adds files.
File storage
Folders and files shared over the network by many servers.
Object storage
Whole files stored by name (key), read and written over HTTPS.
Bucket / key
Bucket = top-level container. Key = the file's full name inside it.
Metadata
Facts about a file: size, type, owner, upload date.
Pre-signed URL
A link with a time-limited signature that lets anyone holding it upload or download.
Content hash
A fingerprint of the bytes. Same bytes → same fingerprint.
Inverted index
Word → list of documents containing it. Like a book's back index.
Analyzer
Turns raw text into clean search words (split, lowercase, trim).
Shard / replica
Shard = one slice of an index. Replica = a copy of a slice.
CDC
Change data capture: reading the database's own change log as events.
Trie
A tree where each step down adds one letter. Good for prefixes.
OLTP / OLAP
OLTP = many small reads/writes. OLAP = big scans that sum things up.
Bloom filter
Tiny bit array that says "surely not here" or "maybe here".

Storing files

01Block, file and object storage

Three ways to keep bytes, and when each one wins core

Three kinds of storage an app server can use An app server talks to a block disk (EBS) using raw blocks, to a shared folder (EFS) using NFS files, and to an object store (S3) over an HTTPS API. RAW BLOCKS NFS FILES HTTPS API Your appEC2 server Block diskEBS volume Shared folderEFS · NFSv4 Object storeS3 bucket LEGEND Server Storage Default for user files
Same bytes, three different "doors" to reach them.

Block storage is a raw disk. It only knows numbered blocks. Your operating system puts a file system on top. It is fast and low-level. Databases love it.

File storage is a shared folder. Many servers mount it at once over the network and see the same files and folders.

Object storage stores whole files by name. You talk to it with web calls (PUT, GET, DELETE). There are no real folders and no "edit byte 500".

In everyday words
Block storage is a blank notebook only you write in. File storage is a shared office cabinet. Object storage is a huge warehouse: you hand over a box with a label and get it back by the label.
KindAWS exampleHow you use itShared by many servers?Best for
BlockEBSMounted as a disk on one EC2 serverNo (except io1/io2 Multi-Attach, up to 16 Nitro servers in one AZ)Database files, boot disks
FileEFSMounted over NFSv4.0/4.1YesShared home folders, CMS uploads, legacy apps that need a path
ObjectS3HTTPS API by keyYes, from anywherePhotos, videos, backups, logs, data lakes
Actually
EBS volumes are copied across many servers, but only inside one Availability Zone (one data-center group). EBS io2 Block Express is designed for 99.999% durability; other types 99.8–99.9%. S3 Standard is designed for 99.999999999% (11 nines) and keeps data in 3 or more AZs. That is why your user files go to S3, not to a server's disk. Source: EBS user guide, S3 storage classes.

Database files → block. Files many servers must open by path → file. Everything users upload → object.

Sources: What is Amazon EBS · EBS Multi-Attach · What is Amazon EFS · checked 2026-10-25

02The object storage model

Bucket, key, metadata, whole-object writes, lifecycle tiers core

A bucket is the top container. Inside it, each object has a key, which is its full name, for example photos/puppy.jpg. The slash is just a character. "Folders" are only name prefixes.

An object is the bytes plus metadata: name-value pairs like Content-Type or your own x-amz-meta-* fields (user metadata is limited to 2 KB).

Objects are replaced, not edited. To change one byte you upload the whole object again. Turn on versioning to keep old copies.

Actually
S3 gives strong read-after-write consistency for PUT and DELETE in all Regions. A read that starts after a successful write sees the new data. Updates to one key are atomic: a reader gets the old object or the new one, never half. But there is no locking: two writers at once → "latest timestamp wins". There are no transactions across keys. Source: S3 consistency model.

Lifecycle tiers: pay less for cold data

A lifecycle rule moves objects to cheaper classes as they age, or deletes them. Cheaper classes have a catch: a minimum storage time you pay for even if you delete early, plus retrieval fees.

Minimum billed storage time by S3 class

Days you pay for even if you delete sooner. Hover a bar for details. From the S3 storage-class comparison table, checked 2026-10-25.

Glacier Deep Archive
180d
Glacier Flexible Retrieval
90d
Glacier Instant Retrieval
90d
Standard-IA
30d
One Zone-IA
30d
Standard
0d
Show the full table
ClassUse when data is read…Access timeAZsMin daysDesigned availability
S3 Standardmore than once a monthmilliseconds≥3099.99%
Standard-IAabout once a monthmilliseconds≥33099.9%
One Zone-IAabout once a month, can be re-createdmilliseconds13099.5%
Glacier Instant Retrievalonce a quartermilliseconds≥39099.9%
Glacier Flexible Retrievalonce a yearminutes to hours (restore first)≥39099.99% after restore
Glacier Deep Archiveless than once a yearhours (restore first)≥318099.99% after restore
S3 lifecycle rule · move logs to cheaper tiers, delete after a year
{ "Rules": [{
    "ID": "logs-tiering", "Status": "Enabled",
    "Filter": { "Prefix": "logs/" },
    "Transitions": [
      { "Days": 30,  "StorageClass": "STANDARD_IA" },
      { "Days": 90,  "StorageClass": "GLACIER" } ],
    "Expiration": { "Days": 365 } }] }
In everyday words
Your desk, then a filing cabinet, then a storage unit across town. Each step is cheaper, but slower to fetch from, and the storage unit makes you rent for six months at least.

Object stores are simple on purpose: whole-object writes, one key at a time. Use lifecycle rules, but check minimum days before moving small or short-lived files.

Sources: What is Amazon S3 · S3 storage classes · S3 Lifecycle · Uploading objects (2 KB user metadata) · checked 2026-10-25

03Big uploads: multipart and pre-signed URLs

Let the client send bytes straight to storage interview favourite

Direct upload with a pre-signed URL The browser asks the API for an upload URL, the API returns a signed URL, the browser puts the file straight into the S3 bucket, and the API saves a metadata row in the database. 1 ASK 2 URL 3 PUT FILE 4 SAVE Browserhas the file Your APIsigns the URL Metadata DBone row S3 bucketgets the bytes LEGEND Client Your service Database Bytes go here
Your server hands out a permission slip. The heavy bytes skip your server.

Why not upload through your API? Every byte would pass through your servers. That costs bandwidth, memory and time, and it ties up a server for minutes.

A pre-signed URL fixes this. Your API signs a URL with its own AWS credentials. The URL says "PUT this one key, until this time". The browser uploads straight to S3. Then the client, or an S3 event, tells your API the upload is done, and the API saves the row (step 4).

Multipart upload splits one big file into parts. Parts go up in parallel and in any order. A failed part is retried alone. S3 joins the parts at the end.

Limit (S3)Value
Biggest single PUT5 GB
When to start using multipartaround 100 MB (AWS advice)
Part size5 MiB to 5 GiB (last part can be smaller)
Parts per upload10,000
Biggest object48.8 TiB (the upload page says "50 TB"; older guides say 5 TB, now out of date)
Pre-signed URL lifeup to 7 days with SigV4 and IAM user keys; console: 1 min–12 h; never longer than temporary credentials
Python · boto3 · make an upload URL that lives 15 minutes
url = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": "media", "Key": f"u/{user_id}/{uuid4()}.mp4",
            "ContentType": "video/mp4"},
    ExpiresIn=900)          # seconds; keep it short
In everyday words
A valet key. It opens one door, for a short time, and you do not have to drive the car yourself.
Actually
The URL carries a signature made from the signer's credentials. S3 checks it, and the expiry, when the request starts. A download that started before expiry keeps going. If the signer's temporary role session ends, the URL dies early, even if you asked for 7 days. AWS calls pre-signed URLs "bearer tokens": anyone who has the link can use it. The same idea is the Valet Key pattern in the Azure Architecture Center. Source: S3 pre-signed URLs, Valet Key pattern.

Your API grants permission; storage moves bytes. Use multipart for anything above about 100 MB, and set a lifecycle rule to clean up unfinished multipart uploads.

Sources: S3 multipart limits · Uploading objects · Multipart upload overview · Pre-signed URLs · checked 2026-10-25

04Storing large files: chunks, dedup, and the metadata split

How Dropbox-style systems avoid sending the same bytes twice deep dive

Chunking and deduplication by content hash A file is cut into 4 MB blocks and each block is hashed with SHA-256. The server checks each hash. New blocks are uploaded to the blob store; duplicate blocks are skipped. SPLIT HASHES NEW SEEN Your fileany size Cut + hash4 MB · SHA-256 Hash known?server index Skip uploadalready stored Blob storenew blocks LEGEND Input Client step Key check Skipped Storage
Only blocks the server has never seen travel over the network.

Chunking means cutting a file into blocks. If you edit one page of a 1 GB file, only the changed blocks need to go up again.

Deduplication means storing each unique block once. The block's name is its content hash. The same bytes always give the same hash, so the server can say "I already have that one".

A file then becomes a list of block hashes. That list lives in a database. The bytes live in the blob store. This is the classic metadata + blob split.

WhereWhat it holdsWhy there
Metadata DB (SQL)files(id, owner_id, path, size, version), file_blocks(file_id, seq, block_hash), sharing rulesSmall rows, transactions, joins, "list my folder" queries
Blob store (S3)Block bytes, keyed by block_hashCheap, durable, endless space, served by CDN
In everyday words
A library keeps one copy of each book on the shelves (blob store) and a card catalogue that says which shelf (metadata DB). Two readers asking for the same book do not get two copies printed.
Actually
Dropbox's own engineering blog says: "Files in Dropbox are split up into 4MB blocks… Each block is addressed by the SHA-256 hash of its contents." Fixed-size blocks have a weakness: insert one byte at the start and every block boundary shifts. Content-defined chunking (cut where a rolling hash of the bytes hits a pattern) keeps boundaries stable; the LBFS paper (SOSP 2001) introduced it for file systems. Cross-user dedup can leak whether someone already has a file, a known side channel (Harnik et al., 2010). Source: Dropbox: Inside LAN Sync, LBFS paper.

Name blocks by their hash, keep a block list per file in SQL, and keep the bytes in object storage.

Sources: Dropbox tech blog · Muthitacharoen et al., A Low-bandwidth Network File System, SOSP 2001 · Harnik, Pinkas, Shulman-Peleg, Side Channels in Cloud Services, IEEE S&P 2010

Search

05From LIKE to the inverted index

Why the database scan fails, and what search engines do instead core

WHERE body LIKE '%cat%' has a wildcard at the front. A normal B-tree index is sorted from the first letter, so it cannot jump to "anything containing cat". The database reads every row. With 100 million rows, that is every query, every time.

It also gets the meaning wrong. It matches "concatenate". It misses "Cats". It cannot rank the best result first.

Building an inverted index Two documents, "The Cat sat" and "the cat ran", pass through an analyzer that splits and lowercases them. The inverted index maps the to docs 1 and 2, cat to docs 1 and 2, sat to doc 1 and ran to doc 2. Doc 1The Cat sat Doc 2the cat ran Analyzersplit + lower Inverted index the → 1, 2 cat → 1, 2 sat → 1 ran → 2 LEGEND Document Text processing The index
Instead of "document → words", store "word → documents".

Search for cat: look up one entry, get docs 1 and 2. Search cat sat: take both lists and keep the docs in both (an intersection). Each list is sorted by doc id, so the merge is fast.

Analyzers: the same cleaning at write time and at query time

An analyzer has three parts, run in order: character filters (for example, strip HTML), exactly one tokenizer (split text into words), and token filters (lowercase, drop stop words like "the", add synonyms, cut words to their stem so "running" matches "run").

Elasticsearch · see what an analyzer does
POST _analyze
{ "tokenizer": "standard", "filter": ["lowercase", "stop"],
  "text": "The Cat sat" }
→ tokens: "cat", "sat"   ("the" removed by the stop filter)
In everyday words
The index at the back of a textbook. You do not read all 600 pages to find "photosynthesis"; you look it up and jump to pages 41, 87 and 203.
Actually
Elasticsearch stores data in Lucene segments; the docs say "a segment is similar to an inverted index". PostgreSQL can serve LIKE '%foo%' with a pg_trgm GIN or GiST index that breaks strings into 3-letter pieces; its docs say "unlike B-tree based searches, the search string need not be left-anchored". That fixes speed, not ranking or word meaning. Source: Anatomy of an analyzer, PostgreSQL pg_trgm.

Use the database for exact lookups. Use an inverted index when users type words and expect ranked results.

Sources: Elastic: analyzer anatomy · Elastic: near real-time search · PostgreSQL: pg_trgm

06Ranking: TF-IDF and BM25

Which matching document comes first core

Finding matches is half the job. Ranking them is the other half. Two ideas drive it:

  • Term frequency (TF): a word that appears often in a document makes it more relevant.
  • Inverse document frequency (IDF): a rare word ("kubernetes") matters more than a common one ("the").

TF-IDF multiplies the two. BM25 fixes two of its flaws. First, TF saturates: the 50th "cat" adds almost nothing. Second, it normalises length: a long document is not rewarded just for having more words.

score(D, Q) = Σ over q in Q of  IDF(q) · f(q,D)·(k1 + 1)
                                 ─────────────────────────────────
                                 f(q,D) + k1·(1 − b + b·|D| / avgdl)
f = times q appears in D · |D| = length of D · avgdl = average length
k1 = how fast TF saturates (default 1.2) · b = how much length matters (default 0.75)
Actually
BM25 is the default similarity in Elasticsearch, with k1 = 1.2 and b = 0.75. The formula comes from the probabilistic relevance framework of Robertson and Zaragoza. Source: Elastic: similarity module, Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (2009).

In an interview, say: "BM25 = TF-IDF with saturation and length normalisation; it is the default in Lucene-based engines."

Sources: Elastic similarity docs · Robertson & Zaragoza 2009

07Inside Elasticsearch / OpenSearch

Index, shards, replicas, and the 1-second refresh deep dive

How a search runs across shards A client sends a query to any node. That node acts as coordinator, sends the query to Node A holding primary shard 0 and replica 1, and to Node B holding primary shard 1 and replica 0, then merges the results. ASK Clientsearch query Any nodecoordinates Node AP0 + R1 Node BP1 + R0 LEGEND Caller Scatter, then gather Data node (P = primary, R = replica)
Two primary shards, each with one replica on the other node. Lose a node and no data is lost.

An index is like a table. It is split into primary shards; each shard is a full Lucene index. Each primary has replica copies on other nodes. Replicas give safety and extra read capacity.

A search goes to every shard (scatter). Each shard returns its top hits. The coordinating node merges them (gather). More shards → more parallel work, but also more overhead per query.

SettingDefaultCan change later?
index.number_of_shards1No, fixed at creation (use split/shrink or reindex)
index.number_of_replicas1Yes, any time
index.refresh_interval1s (Serverless: 5s)Yes; -1 turns it off during bulk loads
Shard size advice10–50 GB, under 200 million docsGuideline, not a hard limit

Near-real-time: why a new document is not instantly searchable

A write goes to a memory buffer and the translog (a safety log on disk). It becomes searchable only after a refresh writes a new segment and opens it.

In everyday words
A newspaper prints an edition every second. Your story is written, but readers see it in the next edition.
Actually
"By default, Elasticsearch periodically refreshes indices every second, but only on indices that have received one search request or more in the last 30 seconds." An index nobody searches stops refreshing until the next search arrives (index.search.idle.after). OpenSearch documents the same 1s default and the same idle rule. If a test must see its write, use ?refresh=wait_for on that request, not a forced refresh on every write. Source: Elastic: near real-time search, OpenSearch index settings.

Search is "near real-time": about one second behind. Pick primary shard count early, by expected data size.

Sources: Elastic: index settings · Elastic: size your shards · OpenSearch index settings · checked 2026-10-25

08Keeping search in sync with the database

The dual-write trap, and change data capture interview favourite

Change data capture keeps the search index in sync The app writes only to the database. A change reader such as Debezium reads the write-ahead log, publishes events to a Kafka topic, and a consumer updates the search index. A dashed path shows the risky dual write from the app straight to the search index. WRITE WAL EVENTS INDEX DUAL WRITE Your appwrites once DatabasePostgres Change readerDebezium Event logKafka topic Search indexElasticsearch LEGEND Data store Reads the change log Risky dual write
The database is the single source of truth. Search is a copy built from its change log.

Dual write means your app writes to the database and then to the search index. It looks simple. It breaks in quiet ways:

  • The DB write succeeds, then the app crashes before the search write. Search is now missing data, forever.
  • Two updates race. The DB ends with version B, but search ends with version A.
  • Search is down. Do you fail the user's save? Retry? Where do you keep the retry?

Change data capture (CDC) avoids this. The app writes only to the database. A reader tails the database's own log of committed changes and turns each change into an event. A consumer applies the events to search, in commit order.

The transactional outbox is the app-level cousin: write the event into an outbox table in the same transaction as the business change, then a relay publishes it.

In everyday words
Instead of a shop clerk writing each sale in two notebooks (and sometimes forgetting one), a helper photocopies the main notebook's new pages every few seconds.
Actually
Debezium's PostgreSQL connector uses Postgres logical decoding over the streaming replication protocol (output plugin pgoutput) to read row changes from the write-ahead log, then writes them to Kafka topics. microservices.io states the outbox guarantee: "messages are guaranteed to be sent if and only if the database transaction commits". Consumers must be idempotent (safe to apply twice), because delivery can repeat. Source: Debezium PostgreSQL connector, Transactional outbox.

Never make search the source of truth. Feed it from the database's log, and keep a "rebuild index from DB" job ready.

Sources: Debezium docs · microservices.io: Transactional outbox

09Typeahead and autocomplete

Tries, top-k at each node, and caching interview favourite

A trie for autocomplete From the start node, the letter c leads to the prefix ca. The ca node stores its top three completions. Below it are the words cat, car and cap. start c catop-3 stored cat car cap LEGEND Prefix node Answer cached here Whole word
Typing "ca" walks two steps. The answer list is already waiting at that node.

A trie (say "try") is a tree of letters. Walking down c → a takes as many steps as the prefix is long, no matter how many words exist.

The naive way then walks every word under "ca" and sorts by popularity. That is slow for short prefixes like "a". The fix: store the top-k completions inside each node. A lookup is then "walk the prefix, return the list".

  • Build the trie offline (for example, hourly) from query logs. Swap in the new one. Writes never block reads.
  • Cache hot prefixes at the edge or in Redis. Short prefixes are few and very hot.
  • Shard by prefix when it outgrows one machine.
  • On the client: wait about 100–300 ms after typing stops (debounce) before calling. This range is common practice, not from a spec.
In everyday words
A dictionary with thumb tabs. You flip straight to "C", then "Ca", and a sticky note there lists the three words people look up most.
Actually
Elasticsearch's completion suggester uses "an in-memory data structure called an FST", a finite state transducer: a compressed cousin of a trie that also shares word endings. It builds one FST per segment at index time, so it scales across nodes like normal search. Source: Elastic blog: You complete me, Elastic: suggesters.

Typeahead = trie (or FST) + top-k per node + offline rebuild + heavy caching.

Sources: Elastic blog · Elastic suggesters docs

Analytics and special stores

10Warehouses, column storage and data lakes

Why "sum all sales by month" needs a different database core

OLTP (online transaction processing) is your app's database: many tiny reads and writes of whole rows. "Get order 42". "Insert one payment".

OLAP (online analytical processing) is reporting: few huge queries that read millions of rows but only a few columns. "Total revenue by country by month".

Row store (OLTP)Column store (OLAP)
A disk block holds whole rows: [id, name, country, amount]A disk block holds one column for many rows: [amount, amount, amount…]
Great for "fetch one order with all fields"Great for "sum one field over a billion rows"
Mixed types per block → weaker compressionSame type per block → strong compression
Postgres, MySQLRedshift, BigQuery, Snowflake, ClickHouse
In everyday words
A row store is a stack of filled forms. A column store cuts the forms apart and keeps all the "amount" boxes in one pile, so adding them up means picking up one pile.
Actually
Amazon Redshift's docs give the math: with 100 columns, a query that uses 5 reads "about five percent of the data contained in the table". Redshift uses 1 MB blocks, versus the typical 2–32 KB of row databases. Source: Redshift: columnar storage.

Data lake vs data warehouse

A data warehouse holds cleaned data in fixed tables. The shape is set before loading (schema-on-write). A data lake keeps raw files of any kind, often in S3, and applies a shape only when you read (schema-on-read). Many teams now use both: raw lake → cleaned warehouse tables.

Never run heavy reports on the OLTP primary. Copy data (often with CDC) into a column store.

Sources: Redshift columnar storage · AWS: What is a data lake

11Time-series databases

Metrics, sensor data, prices: append-only, by time briefly

Time-series data is a stream of (timestamp, value, labels) points. New points arrive all the time. Old points never change. Queries ask for ranges: "CPU of host-7, last 6 hours, per minute".

A time-series database (Prometheus, InfluxDB, TimescaleDB) is built for that shape:

  • Compression: store the change between neighbour points, not the full value. Close numbers compress very well.
  • Time blocks: data is cut into blocks by time, so "drop data older than 15 days" means deleting whole blocks.
  • Downsampling: keep per-second data for days, per-hour data for years.
Actually
Prometheus groups data into 2-hour blocks with a write-ahead log, stores "an average of only 1-2 bytes per sample", and keeps 15 days by default. Facebook's Gorilla paper (VLDB 2015) describes the delta-of-delta timestamp and XOR value compression that many such stores use. Source: Prometheus storage, Gorilla paper.

Metrics in a normal SQL table work at small scale. At millions of points per second, use a time-series store.

Sources: Prometheus storage docs · Pelkonen et al., Gorilla, VLDB 2015

12Bloom filters

"Definitely not here" in a few bits interview favourite

Adding an item to a Bloom filter The word cat goes through three hash functions, H1, H2 and H3, which point to bit positions 1, 6 and 10 of a 12-bit array. Those three bits are set to 1; the rest stay 0. H1 H2 H3 Add "cat"3 hash functions 0 1 0 0 0 0 1 0 0 0 1 0 LEGEND Item added Bit set to 1 Bit still 0
To check "dog": hash it the same 3 ways. If any of its bits is 0, "dog" was never added.

A Bloom filter is a row of bits, all 0 at the start. To add an item, hash it with k different hash functions and set those k bits to 1.

To check an item, look at its k bits. Any bit is 0 → the item is surely not in the set. All bits are 1 → it is probably in the set.

Why "probably"? Other items may have set those same bits by chance. That is a false positive. There are no false negatives. You also cannot delete from a plain Bloom filter, because a bit may be shared.

false-positive rate  p ≈ (1 − e^(−k·n/m))^k
best number of hashes k = (m/n)·ln 2
n = items added · m = bits · about 9.6 bits per item gives p ≈ 1%
In everyday words
A bouncer with a list of first letters only. "No name starts with Q here" is a sure no. "Someone starts with S" means maybe; go check the full guest list.
Actually
Cassandra keeps a Bloom filter per SSTable (on-disk data file) so a read can skip files that surely lack the key. The default bloom_filter_fp_chance is 0.01 (0.1 for LeveledCompactionStrategy tables); the docs say 0.01 needs about three times the memory of 0.1. Source: Cassandra: Bloom filters, Bloom filter (formulas), original paper: B. H. Bloom, CACM 1970.

Use a Bloom filter in front of a slow lookup when most answers are "no": disk files, caches, "is this username taken?" pre-checks.

Sources: Cassandra docs · Wikipedia: Bloom filter · Bloom 1970, Space/time trade-offs in hash coding

Practice

—When to use what

Situation → pick → why decision table

SituationPickWhy
Users upload photos and videosS3 + pre-signed URL + metadata row in SQLBytes skip your servers; SQL handles listing and permissions
Files over about 100 MB, flaky networkMultipart upload, parallel parts, retry per partA failed part costs one part, not the whole file
Same files uploaded by many usersContent-hash chunks + dedupStore and send each block once
Legacy app needs a shared folder pathEFS (NFS)Many servers mount the same files
Database data filesEBS (block)Low-latency random reads and writes
Logs kept 1 year, rarely readS3 + lifecycle to IA, then GlacierPay less as data cools; watch min days
Free-text search with rankingElasticsearch / OpenSearch fed by CDCInverted index + BM25; DB stays the truth
Small app, simple "contains" searchPostgres pg_trgm or full-text searchNo second system to sync
Search box suggestionsTrie/FST with top-k per prefix + cacheAnswer in one walk, no sorting at query time
Dashboards over billions of rowsColumn-store warehouseReads only the needed columns, compresses well
Metrics at high rateTime-series DB (Prometheus etc.)Compression, time blocks, cheap retention
Skip disk reads for missing keysBloom filterFew bits per key, no false negatives

—Common mistakes

Traps that cost marks in interviews and money in production traps

Trap
Uploading through your API servers
Ties up servers and bandwidth. Use pre-signed URLs and direct upload.
Trap
Storing files as DB BLOBs
Bloats backups and replicas, and the buffer cache fills with bytes. Store a key instead.
Trap
Treating S3 "folders" as real
They are key prefixes. Renaming a "folder" means copying every object.
Trap
Long-lived pre-signed URLs
They are bearer tokens. Anyone with the link can use it. Keep minutes, not days.
Trap
Forgetting unfinished multipart uploads
Uploaded parts are stored and billed until you complete or abort. Add a lifecycle cleanup rule.
Trap
Dual writes to DB and search
A crash between writes leaves them out of sync. Use CDC or an outbox.
Trap
Too many tiny shards
Every shard costs memory and query fan-out. Aim for 10–50 GB each.
Trap
Expecting search to show a write instantly
It is near real-time: about 1 s, or later on idle indices.
Trap
Moving small objects to Glacier
Minimum days, 128 KB minimum billing on IA classes and per-object overhead can cost more than you save.

—Interview questions

20 questions, easy to design-level. Try first, then open the answer practice

1. Block vs file vs object storage: when do you use each? easy
Show answer
Block storage is a raw disk attached to one server (EBS). It gives low-latency random I/O, so it suits database files and boot disks. File storage is a shared network file system (EFS over NFS). Many servers mount the same path, which suits legacy apps and shared content folders. Object storage (S3) stores whole files by key over HTTPS. It is cheap, effectively unlimited, and designed for 11 nines of durability across at least 3 AZs, so it is the default for user uploads, backups and data lakes. The trade-off: objects are replaced, not edited in place, and there is no real directory rename. Follow-up: "Can two EC2 servers share one EBS volume?" Only io1/io2 with Multi-Attach, up to 16 Nitro servers in one AZ, and you need a cluster-aware file system.
2. Why not store user images in the database as BLOBs? easy
Show answer
Because the database is your most expensive and hardest-to-scale part. Big BLOBs bloat every backup and every replica, slow replication, and push useful rows out of the buffer cache. You also cannot serve them from a CDN directly. The standard design is the metadata + blob split: the image bytes go to object storage under a key like u/123/abc.jpg; the database row holds the key, size, type, owner and permissions. Reads then go through a CDN or a pre-signed GET URL. Small blobs (a few KB, like avatars in a tiny app) in the DB can be fine; the rule is about scale. Follow-up: "How do you delete safely?" Delete the row first (or mark it), then let a background job delete the object, so you never point at missing bytes.
3. What is a pre-signed URL and why use it for uploads? easy
Show answer
It is a URL your server signs with its own credentials. It allows one action (for example PUT of one key) until an expiry time. The client uploads straight to S3, so your servers never carry the bytes; that saves bandwidth, memory and connection time. S3 checks the signature and expiry when the request starts. Limits: up to 7 days with SigV4 and IAM user keys, but never longer than the temporary credentials used to sign it; the console allows 1 minute to 12 hours. Risks: it is a bearer token, so keep expiry short (minutes), scope it to one key, and set Content-Type when signing. After upload, confirm with an S3 event or a client "done" call before marking the file ready. Follow-up: "How do you limit file size?" Use a pre-signed POST policy with a content-length range.
4. How does S3 multipart upload work, and what are its limits? medium
Show answer
You start an upload and get an upload ID. You send parts, numbered 1 to 10,000, each 5 MiB to 5 GiB (the last part can be smaller). Parts can go in parallel and in any order; each returns an ETag. Finally you call complete with the list of part numbers and ETags, and S3 assembles the object. Benefits: parallel speed, retry of only the failed part, and pause/resume. AWS suggests multipart from about 100 MB; a single PUT caps at 5 GB. The maximum object is now 48.8 TiB in the limits table. Catch: parts of an unfinished upload are stored and billed, so add a lifecycle rule to abort incomplete uploads after some days. Follow-up: "How big should parts be for a 1 TB file?" At least 1 TB / 10,000 ≈ 100 MB per part.
5. Design upload of a 10 GB video from a phone on a flaky network. medium
Show answer
The app calls my API: "start upload, size 10 GB". The API creates an S3 multipart upload, saves a row uploads(id, user, s3_upload_id, status=pending), and returns pre-signed URLs for each part, say 100 MB parts → 100 parts. The phone uploads 3–4 parts at a time, records finished parts locally, and retries failed parts with backoff. If the app is killed, it asks the API which parts S3 already has (ListParts) and continues. When all parts are done, the API completes the upload; an S3 event then triggers transcoding. Pre-signed URLs expire in minutes, so the app asks for fresh ones as needed. A lifecycle rule aborts uploads stuck for a week. Follow-up: "What about checksums?" Send a per-part checksum header so S3 rejects corrupted parts.
6. How would you deduplicate files across users? What are the risks? medium
Show answer
Split files into blocks and name each block by its content hash, like Dropbox's 4 MB blocks addressed by SHA-256. A file is a list of block hashes stored in the metadata DB. Before uploading, the client sends the hashes; the server replies with the ones it lacks, and only those are sent. Keep a reference count (or run garbage collection) so a block is deleted only when no file uses it. Risks: (1) a privacy side channel: if upload is instant, a user learns that someone already has that exact file; per-user dedup or always-upload for small files reduces this. (2) Encryption with per-user keys breaks cross-user dedup. (3) Fixed-size blocks shift after an insert at the start; content-defined chunking fixes that. Follow-up: "Hash collisions?" With SHA-256 they are not a practical concern.
7. Why is LIKE '%term%' slow, and what are the options? easy
Show answer
A B-tree index is sorted from the first character, so it helps only left-anchored patterns like 'term%'. A leading wildcard forces a full scan of every row. It is also semantically weak: no word boundaries, no stemming, no ranking, and case issues. Options in order of effort: (1) Postgres pg_trgm GIN index, which splits strings into 3-letter pieces and supports LIKE '%x%' without left-anchoring. (2) Postgres full-text search (tsvector) for word-based search with basic ranking. (3) A dedicated search engine like Elasticsearch/OpenSearch, with analyzers, BM25 ranking, facets and fuzzy matching, kept in sync with CDC. I pick the simplest option that meets the ranking and scale needs, because every extra system adds sync work.
8. Explain an inverted index. How is a two-word query answered? medium
Show answer
An inverted index maps each term to a postings list: the sorted IDs of documents containing it, often with positions and counts. To build it, each document goes through an analyzer (char filters, one tokenizer, token filters like lowercase and stemming). For "cat sat" with AND, you fetch both postings lists and intersect them with a merge walk, since both are sorted; skip pointers let you jump ahead in long lists. For OR you union them. Then you score each candidate with BM25 and keep the top k with a heap. Positions allow phrase queries ("cat sat" next to each other). In Lucene, the index lives in immutable segments that merge in the background. Follow-up: "Why must the same analyzer run at query time?" Otherwise "Cats" in the query would not match the stored term "cat".
9. What does BM25 improve over TF-IDF? medium
Show answer
Both reward terms that are frequent in the document (TF) and rare across the corpus (IDF). TF-IDF grows linearly with term count, so keyword stuffing wins, and long documents win just by being long. BM25 adds term-frequency saturation, controlled by k1 (Elasticsearch default 1.2): the 10th occurrence adds far less than the 1st. It also normalises by document length relative to the average, controlled by b (default 0.75): b = 0 ignores length, b = 1 fully normalises. BM25 is the default similarity in Elasticsearch. In practice you add business signals on top, such as recency, popularity or personalisation, via function scores or a learning-to-rank step. Follow-up: "When would you lower b?" For short fields like titles, where length differences carry little meaning.
10. A user saves a post and does not see it in search. Why? medium
Show answer
Most likely near-real-time refresh. A new document sits in an in-memory buffer and the translog until a refresh writes a new searchable segment. Elasticsearch refreshes every 1 s by default, but only on indices searched in the last 30 s; idle indices refresh on the next search. Second cause: the pipeline. With CDC, the event must flow DB → Debezium → Kafka → indexer, so consumer lag adds seconds. Fixes: show the user's own new post from the database or local state (read-your-own-writes in the UI), use refresh=wait_for on that one write if the UX needs it, and monitor consumer lag. Do not force a refresh on every write; it creates tiny segments and hurts indexing speed. Follow-up: "How would you measure the lag?" Write a timestamp, then alert on now minus last indexed timestamp.
11. How do you choose the number of shards for an index? hard
Show answer
Start from data size, because primary shard count is fixed at index creation (default 1). Elastic's guidance is 10–50 GB per shard and under 200 million documents. So 600 GB of expected data → about 12–20 primaries. Too few shards: huge shards, slow recovery, no parallelism. Too many: each shard costs heap and file handles, and every query fans out to all of them; Elastic notes a thousand 50 MB shards cost much more than one 50 GB shard. Replicas (default 1, changeable any time) add read capacity and survive node loss. For growing time-based data, use one index per day or week (data streams with rollover) so each index can be sized right and old ones dropped. If you guessed wrong, use the split/shrink APIs or reindex behind an alias. Follow-up: "Why use an alias?" To swap indices without changing clients.
12. How do you keep Postgres and Elasticsearch in sync? hard
Show answer
Not with dual writes from the app: a crash between the two writes, or two racing updates, leaves search wrong with no record of it. I make Postgres the single source of truth and use CDC: Debezium reads the write-ahead log through logical decoding and publishes row changes to Kafka in commit order; an indexer consumes them and upserts into Elasticsearch. The indexer must be idempotent, and should use the row version as an external version, so an old event never overwrites a newer document. If I need richer events, I use a transactional outbox table written in the same transaction. I also keep a full reindex job (DB → new index → swap alias) for mapping changes and disaster recovery. Follow-up: "What if Kafka is down?" The WAL keeps changes while the replication slot holds them, so watch slot lag and disk.
13. Design autocomplete for a search box with heavy traffic. hard
Show answer
Requirements first: top 5 suggestions per prefix, under about 100 ms end to end, popularity-ranked, updated hourly is fine. Data: aggregate query logs into (query, count) with a batch job. Build a trie where each node stores its top-k completions, so a lookup is just "walk the prefix, return the list" in O(prefix length). Serialize the trie, ship it to suggestion servers, and hot-swap it each hour; reads never wait for writes. Shard by prefix range if it does not fit in memory, and replicate for load. Put a cache (CDN or Redis) in front, because short prefixes are few and very hot. On the client, debounce keystrokes and cancel stale requests. Filter offensive terms in the build step. Elasticsearch's completion suggester gives a ready FST-based version. Follow-up: "Trending queries?" Add a small real-time layer that boosts recent counts.
14. OLTP vs OLAP: why are warehouses column stores? easy
Show answer
OLTP serves the app: many small transactions touching whole rows, so row storage fits, because one disk read brings the whole record. OLAP serves analysis: few queries that scan millions of rows but read only a few columns. A column store keeps each column's values together, so the query reads only those columns. Redshift's docs give the example: 5 of 100 columns → about 5% of the data read. Same-type values also compress much better, and engines can process them in vectorized batches. Writes are the cost: single-row updates are slow, so warehouses load in batches or micro-batches, often fed by CDC from the OLTP database. Never run heavy reports on the OLTP primary. Follow-up: "Where do read replicas fit?" A replica protects the primary, but it is still a row store, so big scans stay slow.
15. Data lake vs data warehouse? medium
Show answer
A warehouse stores cleaned, structured data in defined tables: schema-on-write. It is fast for SQL and trusted for business reports, but you must model data before loading. A data lake stores raw data of any type, often as files (Parquet, JSON, images) in S3: schema-on-read. It is cheap and flexible, good for data science and for keeping raw history you may need later, but without care it becomes a "data swamp" of unknown files. Modern setups combine them: land raw data in the lake, clean it with jobs, and publish curated tables, often in an open table format like Apache Iceberg, which engines such as Athena, Redshift and Spark can query. Follow-up: "Why Parquet?" It is a columnar file format, so lake queries also read only needed columns.
16. Why use a time-series database instead of Postgres for metrics? medium
Show answer
Metrics are append-only points, written at a very high rate and queried by time range and labels. A general database pays for features you do not need (row updates, transactions, per-row indexes) and stores each point in tens of bytes. A time-series store compresses neighbouring points (delta-of-delta timestamps, XOR values, as in Facebook's Gorilla paper); Prometheus averages 1–2 bytes per sample. It cuts data into time blocks (2 hours in Prometheus), so retention is a cheap block delete, and it supports downsampling and range functions like rate(). At small scale, Postgres with TimescaleDB or a simple partitioned table is fine. Watch out for high cardinality: each unique label set is a new series, so never put user IDs in labels. Follow-up: "How long do you keep raw data?" Days raw, months or years downsampled.
17. What is a Bloom filter? Where would you use one? medium
Show answer
A bit array plus k hash functions. Adding an item sets its k bits. Checking an item: if any of its bits is 0, it is definitely absent; if all are 1, it is probably present. False positives happen because other items set those bits; false negatives never happen. The rate is about (1 − e^(−kn/m))^k, and about 9.6 bits per item gives 1%. Uses: LSM-tree databases like Cassandra keep one per SSTable so reads skip files that cannot hold the key (default false-positive chance 0.01); caches use them to avoid lookups for keys never stored; crawlers use them for "seen this URL?". You cannot delete from a plain Bloom filter; a counting Bloom filter fixes that at extra memory cost. Follow-up: "What happens when it fills up?" The false-positive rate climbs, so size it for the expected n or rebuild.
18. What breaks if your search cluster goes down? hard
Show answer
If search is derived data, nothing is lost: the database is the truth and the CDC pipeline (Kafka keeps events for its retention period) catches up when search returns. User impact: the search box fails. Degrade gracefully: hide search or show "search is unavailable", fall back to a simple DB query for exact lookups (by ID or prefix with a B-tree), and keep the rest of the product working; never block saves on search. Protect the cluster with replicas (at least 1 per shard), spread across AZs, and use dedicated master nodes. If the index is corrupted or the mapping must change, rebuild into a new index from the DB and swap an alias. Watch consumer lag after recovery. Follow-up: "What if Kafka retention ran out?" Then you do a full reindex from the database.
19. Design the storage layer of a Google Drive / Dropbox clone. hard
Show answer
Split metadata from bytes. Metadata in a sharded SQL database (shard by user or namespace): files, file_versions, blocks(hash, size, refcount), version_blocks(version_id, seq, hash), sharing ACLs. Bytes in S3, one object per block keyed by its SHA-256 hash (Dropbox uses 4 MB blocks). Upload: the client chunks and hashes, asks which hashes are missing, uploads those with pre-signed URLs, then commits a new version (a list of hashes) in one DB transaction. Download: fetch the block list, get blocks via CDN or pre-signed GETs. Sync: each change bumps a per-user cursor; clients long-poll or use a notification service and pull changes since their cursor. Conflicts: keep both copies ("conflicted copy"). Old versions and blocks with refcount 0 go to cheaper tiers or garbage collection. Follow-up: "Search inside documents?" Extract text asynchronously and feed an index through CDC.
20. You moved all S3 data to Glacier to save money and the bill went up. Why? medium
Show answer
Cold classes have hidden costs. Minimum storage duration: 90 days for Glacier Instant and Flexible Retrieval, 180 for Deep Archive, so deleting early still bills the full minimum. Minimum billable size of 128 KB for IA and Glacier Instant Retrieval, so millions of tiny objects cost more than they should; Flexible Retrieval and Deep Archive add 40 KB of metadata per object. Per-GB retrieval fees, and Flexible/Deep Archive objects must be restored first (minutes to hours), so frequent reads are expensive and slow. Transition requests also cost money per object. Fix: use lifecycle rules only on data you rarely read, filter by size, batch small files into bigger archives first, or use Intelligent-Tiering when the access pattern is unknown (it skips objects under 128 KB and has no retrieval fees). Follow-up: "How do you find cold data?" S3 Storage Class Analysis or Storage Lens.

—Cheat sheet

Numbers and facts to remember memorise

FactValueSource
S3 single PUT max5 GBS3 Uploading objects
S3 multipart parts10,000 parts, 5 MiB–5 GiB eachS3 multipart limits
S3 max object48.8 TiB ("50 TB")S3 multipart limits
S3 Standard durability99.999999999%, ≥3 AZsS3 storage classes
S3 consistencyStrong read-after-write for PUT/DELETES3 user guide
Pre-signed URL max7 days (SigV4, IAM user)S3 pre-signed URLs
Min days: IA / Glacier IR, Flexible / Deep Archive30 / 90 / 90 / 180S3 storage classes
Dropbox block4 MB, named by SHA-256Dropbox tech blog
ES refresh1 s, only if searched in last 30 sElastic near real-time
ES defaults1 primary shard, 1 replicaElastic index settings
Shard size advice10–50 GB, under 200M docsElastic: size your shards
BM25 defaultsk1 1.2, b 0.75Elastic similarity
Bloom filter for 1% FPabout 9.6 bits per itemBloom filter (Wikipedia)
Cassandra Bloom FP default0.01 (0.1 for LCS)Cassandra docs
Prometheus1–2 bytes/sample, 2 h blocks, 15 d retentionPrometheus storage

Reference

—Sources

All checked on 2026-10-25 official docs

Course

—Where next

Keep going through the course navigation