← AI + Backend
Data & Storage for AI

Object Storage & Data Lakes

Watch: "FAST '23 - Building and Operating a Pretty Big Storage System (My Adventures in Amazon S3)" — USENIX

Object storage (Amazon S3, Google Cloud Storage, Azure Blob Storage and compatible systems) is a flat, key-addressed store of immutable blobs that is cheap per gigabyte, very durable, and scales far beyond any single database. Almost every AI platform ends up on it: training datasets, model checkpoints, embedding dumps, request logs, and the raw input of every data pipeline. Interviewers probe it because it looks like "a big disk" and behaves very differently: no in-place updates, no real directories, a per-request cost and latency, and query performance that depends entirely on the file format and layout you choose. For a Backend or AI engineer, the useful skill is explaining why a query over a data lake is fast or slow, what a small-file problem is, and what a table format such as Iceberg or Delta adds on top of raw files.

What Is Object Storage?

Object storage is a place to store huge numbers of files, called "objects," as simple flat items identified by a unique key, rather than organized in the nested folders of a traditional hard drive. Instead of editing a file in place, you replace the whole object when it changes. It is built to hold enormous amounts of data cheaply and reliably, but it is not optimized for the fast, precise lookups a database is designed for.

What Is a Data Lake?

A data lake is a large collection of raw and semi-processed data, of many different types and formats, stored centrally, usually in object storage, so that many different teams and tools can use it later. Rather than deciding up front exactly how the data will be structured and queried the way a database requires, a data lake keeps the raw files around so they can be reorganized, reprocessed or analyzed however future needs require.

Where This Is Used

A Day-to-Day Example

Think of a huge self-storage warehouse. Object storage is like renting endless numbered storage units: you don't get to rearrange them into aisles or shelves, you just label a unit and put a box in it, then pull the whole box out again later, never editing what's already inside. A data lake is the entire warehouse's practice of keeping every box exactly as it arrived, receipts, records, whatever comes in, because sorting through it and deciding what matters can happen later, whenever someone actually needs it.

Deep Dive: Object Storage Semantics

Objects, prefixes and multipart uploadbucket: flat keys; "directories" are just prefixeslogs/2026/09/20/part-0.parquet → immutable bytesPUT: whole objectGET: whole or rangeLIST prefix · DELETEno in-place update; rename = COPY + DELETE (not atomic)multipart uploadpart 1part 2part 3part Ncomplete → one objectparts upload in parallel and retry independently
An object store is a flat map from key to immutable bytes. You replace whole objects, read byte ranges, list by prefix, and upload large files as parts that are stitched together on completion. A filesystem, by contrast, supports real renames and in-place writes.
objectparts
Object store (S3-style)                         Filesystem / block device

  bucket/                                         /data/logs/2026/09/20/part-0.log
    key: "logs/2026/09/20/part-0.parquet"         real directories, rename is one metadata op,
    value: immutable bytes + metadata             in-place overwrite / append of a byte range
    "directories" = key prefixes (a naming convention)

  PUT key            -> stores the WHOLE object (a new object replaces the old one)
  GET key [range]    -> whole object or a byte range
  LIST prefix        -> keys sharing a prefix (paged, lexicographic)
  DELETE key
  no partial in-place update; "rename" = COPY + DELETE (not atomic, cost grows with size)

  Large objects: MULTIPART UPLOAD
    part 1 | part 2 | ... | part N   (parts uploaded in parallel, retried independently)
                    \____ complete: the store assembles them into one object ____/

An object is immutable in the sense that you cannot modify a byte range in place; you write a new object under the key (the store replaces the old one) and readers see either the old or the new version. That single fact shapes everything else: append-style workloads write many new files; "updating a row" means rewriting a file or using a table format that records changes in metadata. Reads can fetch byte ranges, which is what makes columnar formats efficient. Multipart upload splits a large object into parts uploaded in parallel and retried independently, then completes them into one object; on S3 each part except the last has a minimum size (currently 5 MiB) and an upload can have up to 10,000 parts, but check the current documentation of your provider. Incomplete multipart uploads keep costing storage until aborted, so lifecycle rules normally clean them up.

Consistency is provider-specific, so hedge it. Amazon S3 states that it provides strong read-after-write consistency for object PUTs and DELETEs and strongly consistent LIST since December 2020; other providers and S3-compatible systems differ, and older material still describes S3 as eventually consistent, so verify with current documentation. What no object store gives you is a multi-object transaction: two files written by one job appear independently, which is why readers can see half of a job's output unless a commit protocol or table format hides it. Also, request rates are spread by key prefix or internal partitioning, and very hot key ranges can be throttled (S3 documents a per-prefix request rate, check current numbers), so key naming and fan-out matter at high request rates.

Deep Dive: Why Columnar Formats Win for Analytics

Row versus columnar layoutrow-oriented: SUM(tokens) reads every byteuidtsmodeltokenslatencypromptuidtsmodeltokenslatencypromptcolumnar (Parquet): row groups × column chunksuid chunkts chunkmodel chunktokens chunklatency chunkprompt chunkrow group 1uid chunkts chunkmodel chunktokens chunklatency chunkprompt chunkrow group 2footer: schema, chunk offsets, per-chunk min/maxSUM(tokens) → read footer, range-GET only the tokens chunksWHERE ts BETWEEN a AND b → skip row groups whose min/max can't match
Summing one column over row-oriented files reads everything. Parquet stores each column of a row group contiguously, and the footer records where each chunk is and its min/max, so a query fetches only the columns it needs and skips row groups that can't match.
bytes read (row)bytes read (columnar)skipped
Show text version
Row-oriented (CSV / JSON lines / row files):
  [uid ts model tokens latency prompt][uid ts model tokens latency prompt] ...
  SELECT SUM(tokens)  -> must read every byte of every row

Column-oriented (Parquet, ORC):
  file = row groups; each row group = one COLUMN CHUNK per column
  +-------------------- row group 1 -----------------+---- row group 2 ---- ...
  | uid chunk | ts chunk | model chunk | tokens chunk | ...
  +------------------------------------------------------------------+
  footer: schema, chunk offsets, per-chunk min/max statistics
  SELECT SUM(tokens) -> read footer, then range-GET only the tokens chunks
  WHERE ts BETWEEN a AND b -> skip row groups whose min/max cannot match

Analytical queries typically touch a few columns of many rows, whereas a row layout forces you to read everything to reach one field. A columnar layout stores each column contiguously, so a query reads only the columns it references, and because values in a column are similar they compress and encode well (dictionary encoding for low-cardinality strings such as a model name, run-length and delta encoding for sorted or repetitive values). Parquet also stores per-chunk min/max statistics in the footer so an engine can skip row groups that cannot satisfy a filter (predicate pushdown), and object storage's ranged GET lets the engine fetch just those bytes. The demo below measures the bytes read for a single-column query on a toy layout.

Row formats remain right where the workload is row-shaped: streaming ingestion (append one JSON or Avro record at a time), and small single-record lookups. The usual pipeline lands raw data as JSON or Avro, then compacts it into Parquet for analytics. The costs of columnar: writing needs buffering a whole row group in memory, single-row reads are expensive (you touch many column chunks), and CSV/JSON are still fine for tiny, human-inspected data. Also, a wide "prompt" text column, as in the demo, dominates the size of an AI request log, which is exactly why not reading it for aggregate queries pays off.

Deep Dive: Data Lake vs Warehouse vs Lakehouse, and Table Formats

How a table format commitscatalog pointermetadata filesnapshot 7snapshot 8manifest listsdata files (Parquet)snapshot 8 adds / removes filescommit = write new data files, then atomically swap the pointer to a new metadata version
Iceberg, Delta and Hudi add transactions on top of plain files: each commit writes new files and then atomically moves one pointer to a new snapshot, which gives readers a consistent view and time travel. The lake/warehouse/lakehouse comparison is below.
atomic pointernew snapshot
                 Data lake             Data warehouse          Lakehouse
  storage        object storage,       managed columnar        object storage + open
                 raw / open files      storage, proprietary    files + table format
  schema         schema-on-read        schema-on-write         schema enforced by the
                 (structure later)     (strict, curated)       table format
  transactions   none across files     ACID                    ACID via metadata log
  cost           lowest per GB         higher per GB           near lake cost
  risk           "data swamp"          lock-in, ingest cost    format/engine maturity

Table format (Iceberg / Delta Lake / Hudi, conceptually):

   catalog pointer --> metadata file (snapshot list)
                            |--> snapshot 7 --> manifest lists --> data files (Parquet)
                            \--> snapshot 8 (adds/removes files)
   a commit = write new data files, then atomically swap the pointer to a new metadata version

A raw data lake is just files in object storage plus whatever conventions you impose; without a catalog and schema discipline it turns into a data swamp. A warehouse gives curated tables, SQL and transactions but usually costs more and couples storage to a vendor. A lakehouse keeps the cheap open files and adds a table format (Apache Iceberg, Delta Lake and Apache Hudi are the well-known ones; details differ by format and version) that tracks which data files belong to a table in a metadata log. Because a commit is an atomic swap of a metadata pointer rather than an in-place file edit, readers see a consistent snapshot, writers can add or remove files atomically, and features such as schema evolution and time travel become possible. That atomic swap still needs a primitive the store or a catalog provides (a conditional write, or a catalog service), which is why concurrent writers are a design point to verify per system.

These table formats do not turn object storage into a transactional database: high-frequency small updates still create many small files and metadata, and updates and deletes are typically implemented as new files plus delete markers, cleaned up by later compaction. Say the conceptual shape in an interview and do not claim exact internals you cannot back up.

Deep Dive: Partitioning Layout and the Small-File Problem

The small-file problemreading 1 GB10,000 tiny files10,000 requests + 10,000 footers8 files8 requests, big sequential readspartition pruning: WHERE dt='2026-09-20' lists only …/dt=2026-09-20/partition by low-cardinality filters (date), not user_id; files tens of MB to ~1 GB
Per-file overhead dominates when data is split into thousands of tiny files. Partitioning by date lets a query skip whole prefixes, but partitioning by a high-cardinality column creates exactly the tiny-file problem.
many small filesfew large files
Partitioned layout (hive-style key prefixes):

  s3://lake/requests/dt=2026-09-19/model=large/part-0001.parquet
  s3://lake/requests/dt=2026-09-20/model=large/part-0001.parquet
  ...

  Query WHERE dt='2026-09-20'  -> only that prefix is listed and read (partition pruning)

  Too many partitions / too many tiny files:
    1 GB in 10,000 files : 10,000 requests, 10,000 footers to open, huge LIST + metadata
    1 GB in 8 files      : 8 requests, large sequential reads, footers amortised

  Rule of thumb (varies by engine; verify): files from tens of MB to about a GB;
  partition by low-cardinality, frequently filtered columns (date), NOT by user_id.

Object storage bills and throttles per request, and every file costs a request, a footer read and planning metadata. Streaming jobs that write a small file every few seconds per partition produce the small-file problem: query engines spend more time opening files and listing prefixes than reading data, and metadata operations can dominate. The fixes are batching writes, compacting small files into large ones on a schedule (a table format's compaction/optimize job), and choosing partition columns carefully: partitioning by a high-cardinality key such as user_id explodes the number of partitions and files, while partitioning by date matches typical filters and keeps partitions large. The demo's last lines show the request-count effect with assumed constants and explicitly do not claim measured latency.

Partition pruning and Parquet statistics work together: the partition directory narrows the file set, the footer statistics narrow the row groups, and the column projection narrows the bytes. A layout that sorts or clusters data by a commonly filtered column makes the min/max statistics far more selective.

Deep Dive: Cost, Egress and Storing AI Artifacts

Storage tiers over an object's lifelifecycle for logs/ (days)↓ standard, first 30 daysinfrequent accessarchive30180730delete at 730checkpoints/tmp/ → delete after 7 days · incomplete multipart → abort after 3cost = capacity + requests + retrieval (cold tiers) + egress
Lifecycle rules move data to cheaper, slower tiers as it ages and delete it at the end. Colder tiers charge for retrieval and have minimum durations, and data leaving the region is billed as egress. How to store datasets, checkpoints and weights is below.
hotcoolarchive
Cost dimensions of object storage (check current provider pricing; no numbers assumed here):
  1. capacity      : per GB-month, lower for colder tiers
  2. requests      : per 1,000 PUT/LIST vs GET (PUT/LIST typically costlier)
  3. retrieval     : cold tiers charge per GB retrieved and have minimum storage durations
  4. egress        : data leaving the provider/region (internet or cross-region) is billed per GB

Lifecycle policy example (conceptual):
  logs/            -> infrequent-access after 30 days -> archive after 180 -> delete after 730
  checkpoints/tmp/ -> delete after 7 days
  incomplete multipart uploads -> abort after 3 days

AI artifacts:
  datasets     : versioned prefixes (dataset/v12/...), Parquet or sharded tar/record files, manifest
  checkpoints  : sharded files, path includes run id + step; keep last K + best, expire the rest
  embeddings   : Parquet/npy shards as the durable source; the ANN index is a rebuildable derived copy
  model weights: immutable, content-hashed or versioned path; served pods download at startup

Cost surprises come from requests and egress more than from capacity: millions of small GETs, listing huge prefixes, and repeatedly pulling data across regions or out of the provider. Keep compute in the same region as the data, cache hot artifacts locally (a model pod that downloads tens of gigabytes of weights at every start suffers slow cold starts and pays for the transfer, so baking or caching weights near the node helps), and use lifecycle rules so intermediate checkpoints and old logs do not accumulate forever. Cold tiers save capacity cost but charge retrieval and enforce minimum durations, so they are wrong for data you read often.

For embeddings, keep the vectors and their source chunk IDs in object storage (Parquet) as the durable copy and treat the vector index as a derived structure you can rebuild or reload, which makes reindexing after a model change a batch job (see Vector Databases and Data & Model Versioning). Pair the immutable files with a catalog in a relational database recording schema, owner, lineage and version, since the object store itself knows nothing about them.

Key Pointers

How It Works

  1. A producer (pipeline, service, training job) writes files to a key such as requests/dt=2026-09-20/part-0001.parquet; large files go up as parallel multipart parts that are then completed into one object.
  2. Raw data typically lands first in a raw zone (JSON/Avro/CSV), then a batch or streaming job cleans and compacts it into Parquet files in a curated zone, partitioned by date.
  3. A table format or catalog records which files make up each table version; a commit adds new data files and then atomically publishes a new metadata version.
  4. A query engine plans the query: it prunes partitions by the filter, reads Parquet footers, skips row groups by statistics, and issues ranged GETs for only the needed column chunks.
  5. Lifecycle rules move aging data to colder tiers and delete expired data, and a compaction job merges small files to keep planning and request costs bounded.
  6. ML jobs read dataset versions by immutable path or snapshot ID and write checkpoints and model artifacts back under a run-specific prefix, with a catalog row recording lineage.

Try It Yourself

# Python 3 (standard library only): toy row layout vs column layout, bytes read for SELECT SUM(tokens).
# This is a teaching model of the idea, not the real Parquet file format.
import os, random, struct, tempfile, zlib
random.seed(5)
N = 50_000
MODELS = ["small", "medium", "large"]
rows = []
for i in range(N):
    rows.append((i // 10, 1_700_000_000 + i, random.choice(MODELS),
                 random.randint(20, 900), random.randint(80, 4000),
                 "prompt-%d " % random.randint(0, 999) * random.randint(3, 12)))
# columns: user_id, ts, model, tokens, latency_ms, prompt
tmp = tempfile.mkdtemp()

class CountingFile:
    def __init__(self, path): self.f = open(path, "rb"); self.bytes = 0
    def read(self, n=-1):
        b = self.f.read(n); self.bytes += len(b); return b

# ---- ROW layout: each row stored contiguously (length-prefixed fields)
def enc_row(r):
    uid, ts, model, tok, lat, prompt = r
    p = prompt.encode(); m = model.encode()
    return struct.pack("<qqHii", uid, ts, len(m), tok, lat) + m + struct.pack("<I", len(p)) + p
with open(f"{tmp}/rows.bin", "wb") as f:
    for r in rows: f.write(enc_row(r))

# ---- COLUMN layout: one file per column; fixed-width ints packed together
def write_col(name, fmt, idx):
    with open(f"{tmp}/{name}.bin", "wb") as f:
        f.write(struct.pack("<%d%s" % (N, fmt), *[r[idx] for r in rows]))
write_col("user_id", "q", 0); write_col("ts", "q", 1)
write_col("tokens", "i", 3); write_col("latency_ms", "i", 4)
with open(f"{tmp}/model.bin", "wb") as f:                      # dictionary encoding: 1 byte/value
    f.write(bytes(MODELS.index(r[2]) for r in rows))
with open(f"{tmp}/prompt.bin", "wb") as f:
    for r in rows: b = r[5].encode(); f.write(struct.pack("<I", len(b)) + b)

def size(p): return os.path.getsize(f"{tmp}/{p}")
print("row file    : %9d bytes" % size("rows.bin"))
print("column files: %9d bytes total" % sum(size(c + ".bin") for c in
      ["user_id", "ts", "model", "tokens", "latency_ms", "prompt"]))

# ---- Query: SELECT SUM(tokens)
cf = CountingFile(f"{tmp}/rows.bin"); total_row = 0
while True:                                    # a row store must step through every row
    hdr = cf.read(8 + 8 + 2 + 4 + 4)
    if not hdr: break
    _, _, mlen, tok, _ = struct.unpack("<qqHii", hdr)
    total_row += tok
    cf.read(mlen); (plen,) = struct.unpack("<I", cf.read(4)); cf.read(plen)
cc = CountingFile(f"{tmp}/tokens.bin")
total_col = sum(struct.unpack("<%di" % N, cc.read()))
print("SUM(tokens) row layout   : %d, bytes read = %d" % (total_row, cf.bytes))
print("SUM(tokens) column layout: %d, bytes read = %d  (%.1fx less)" % (total_col, cc.bytes, cf.bytes / cc.bytes))

# ---- Compression: same bytes, compressed per file vs per row block
raw_rows = open(f"{tmp}/rows.bin", "rb").read()
print("zlib(row file)           : %9d bytes" % len(zlib.compress(raw_rows)))
for c in ["model", "tokens", "prompt"]:
    b = open(f"{tmp}/{c}.bin", "rb").read()
    print("zlib(%-10s column)  : %9d -> %9d bytes" % (c, len(b), len(zlib.compress(b))))

# ---- Row-group min/max statistics: skip blocks for a time-range filter (ts is sorted here)
G = 5000
stats = [(min(r[1] for r in rows[i:i+G]), max(r[1] for r in rows[i:i+G])) for i in range(0, N, G)]
lo, hi = 1_700_000_000 + 12_000, 1_700_000_000 + 13_000       # WHERE ts BETWEEN lo AND hi
hit = [g for g, (a, b) in enumerate(stats) if not (b < lo or a > hi)]
print("row groups: %d, must read: %d %s (rest skipped by min/max stats)" % (len(stats), len(hit), hit))

# ---- Small-file problem: request overhead dominates (ILLUSTRATIVE constants)
REQ_MS, MBPS = 20, 100                                           # assumed per-request latency, throughput
def scan_ms(files, total_mb): return files * REQ_MS + total_mb / MBPS * 1000
print("1 GB in 10,000 files (sequential): %.0f s" % (scan_ms(10_000, 1024) / 1000))
print("1 GB in 8 files of 128 MB        : %.1f s" % (scan_ms(8, 1024) / 1000))

# Real output from python3 (data is seeded; sizes in bytes; ratios depend on this data, notably the wide 'prompt' text column):
# row file    :   5842991 bytes
# column files:   5526125 bytes total
# SUM(tokens) row layout   : 23008953, bytes read = 5842991
# SUM(tokens) column layout: 23008953, bytes read = 200000  (29.2x less)
# zlib(row file)           :    830720 bytes
# zlib(model      column)  :     50000 ->     12027 bytes
# zlib(tokens     column)  :    200000 ->     88810 bytes
# zlib(prompt     column)  :   4276125 ->    291079 bytes
# row groups: 10, must read: 1 [2] (rest skipped by min/max stats)
# 1 GB in 10,000 files (sequential): 210 s
# 1 GB in 8 files of 128 MB        : 10.4 s
# (the last two lines are arithmetic on ASSUMED constants: 20 ms per request, 100 MB/s; real engines parallelise requests,
#  so read them as "request overhead scales with file count", not as a benchmark)

Interview Q&A

Q: How is object storage different from a file system, and why does it matter?

A: Objects are immutable blobs in a flat namespace with prefix-based "directories"; you cannot append or modify a byte range in place, and renaming means copy plus delete. That pushes designs toward writing new files and using metadata or table formats to describe the current state, and it makes commit protocols (writing to a temporary location then publishing) important for multi-file jobs. In return you get very high durability, huge scale and low cost per GB.

Q: Why is Parquet faster than CSV for analytics?

A: Columnar storage lets a query read only the columns it uses, values in a column compress and encode well, and per-row-group min/max statistics let engines skip data that cannot match a filter. A row format must read every byte to reach one field. In my toy demo a single-column sum read about 29 times fewer bytes, but that ratio depends on the data (a wide text column exaggerates it); the trade-off is that single-row lookups and streaming appends are worse in columnar files.

Q: Data lake, data warehouse, lakehouse: which do you pick?

A: A warehouse fits curated, SQL-centric BI with strict schemas and high concurrency, at higher cost and some lock-in. A lake is cheapest and holds any format, including unstructured data for ML, but needs governance to avoid a swamp. A lakehouse tries to combine them by adding a table format for ACID and schema evolution over open files; it is attractive when ML and BI share data, provided the team accepts the operational maturity that entails.

Q: What is the small-file problem and how do you fix it?

A: Many tiny objects mean many requests, many footers to open and large listings, so query time and request cost are dominated by overhead, not data volume. Fix it by batching writes into larger files, running a compaction job (or the table format's optimize command), and avoiding over-partitioning, especially by high-cardinality columns. As a rough guide files of tens of MB to about a GB work well, but the sweet spot depends on the engine.

Q: What does a table format like Iceberg or Delta add over plain Parquet files?

A: Conceptually, a metadata log that says which data files constitute each version of the table. Commits are atomic pointer swaps, so readers get consistent snapshots, and it enables schema evolution, time travel, and row-level updates and deletes implemented as new files plus delete information. It does not make the store transactional for high-frequency tiny writes, and concurrent-writer behaviour depends on the catalog and format, so I would check those specifics.

Q: How do you keep object-storage costs under control for an AI platform?

A: Reduce requests (bigger files, fewer listings), avoid unnecessary egress by co-locating compute with data and caching hot artifacts, apply lifecycle rules (tier, expire old logs, keep only the last few checkpoints, abort incomplete multipart uploads), and compress with an appropriate codec. Cold tiers charge retrieval and minimum durations, so they only fit rarely read data.

Q: Where should embeddings live: object storage or a vector database?

A: Both, with different roles: object storage (for example Parquet shards with chunk IDs) is the durable, cheap source of truth that a re-index or a model change can rebuild from, while the vector database or index serves low-latency nearest-neighbour queries. Treat the index as a derived copy, version it with the embedding model, and be able to rebuild it from the stored vectors or the source text.

Follow-up: A nightly job writes 500 output files and a downstream reader sometimes sees only some of them. Why, and how do you fix it?

A: The store has no multi-object transaction, so files become visible one at a time and a reader listing the prefix midway sees a partial result. Fix it with a commit protocol: write to a staging prefix, then publish by writing a manifest or a single success marker (or by committing through a table format's atomic metadata swap), and have readers consume only manifest-listed files. Cost: an extra publish step and a cleanup path for failed runs.

Follow-up: A query filters on user_id but your table is partitioned by date. What are the options?

A: Within each date partition sort or cluster the data by user_id so Parquet min/max statistics skip most row groups, and possibly add a bloom filter if the format supports it. Adding user_id as a partition would create a huge number of tiny files, so it is the wrong tool; if point lookups by user dominate, a key-value or relational serving store is the better fit than the lake. The trade-off is heavier write-time sorting and the fact that the lake stays optimised for scans, not lookups.

Follow-up: A serving pod takes minutes to start because it downloads a large model from object storage. What do you change?

A: Reduce or overlap the transfer: use parallel range downloads, keep the weights in the same region, cache them on node-local disk or a shared volume so restarts and neighbours reuse them, pre-pull or bake images for predictable rollouts, and use smaller or quantized artifacts where quality allows. The trade-off is extra cache invalidation and disk cost versus cold-start latency, which matters most for autoscaling (see Model Serving Architectures).

System Design Tie-in

Almost every ML platform diagram has an object-store box in the middle: ingestion lands raw events, batch jobs curate Parquet tables, training reads dataset versions, and checkpoints and model artifacts are written back for the serving fleet (see Data Pipelines & ETL for ML). A useful ILLUSTRATIVE estimate: assume 10M LLM requests per day, ~3 KB of logged text per request, so about 30 GB per day of raw logs, roughly 11 TB per year before compression; columnar compression commonly shrinks text-heavy logs several-fold, but measure on your data. Storing that in a transactional database would be expensive and would compete with production queries; in object storage as date-partitioned Parquet it is cheap to keep and cheap to scan for cost attribution and evaluation, while a small rolling window may also live in a log or OLAP store for dashboards. State your assumptions, then discuss the operational parts: lifecycle tiers, compaction cadence, the catalog, region co-location to avoid egress, and privacy deletion in immutable files. The related relational-store decision is on Relational vs NoSQL for AI Workloads, and the underlying row/page mechanics that motivate columnar layouts are in Storage Internals & WAL.

Trade-offs

Object storage buys near-unlimited, durable, cheap capacity and decouples storage from compute, at the cost of no in-place updates, no cross-object transactions, per-request latency and cost, and query speed that depends wholly on format and layout. Columnar files make scans cheap but make point lookups and small updates expensive; a lakehouse table format adds transactions and schema evolution over open files at the price of metadata complexity and compaction upkeep. The lake is the wrong choice for low-latency key lookups, frequently updated rows, or workloads that need strict multi-row transactions, where a database serves better. It is also the wrong choice if the team cannot maintain schema, catalog and lifecycle discipline, because a lake without governance becomes a swamp; in that case a managed warehouse may cost more per GB but cost less in engineering time. Choose the layout from the queries, measure file sizes and bytes scanned, and revisit as volume grows.