AWS Architecture

AWS Data Lake and Analytics Architecture: S3, Glue, Athena, Redshift and Kinesis

The finance forward arrives before the coffee does: Athena scanned 2.3 PB last month — $11,500 for forty analysts — and the dashboards are still slow. The “lake” bucket holds 240 million objects averaging 400 KB, three teams disagree about which copy of orders is authoritative, and the engineer who understood the Glue jobs has left. Nobody designed this. It accreted — a Firehose here, a crawler there, a CSV export “just for now” — because on AWS the storage is so cheap and the services so easy to bolt together that the architecture is the only hard part. A data lake is not a bucket with data in it; it is a contract: zoned storage on Amazon S3, columnar formats sized for scan engines, one AWS Glue Data Catalog as the single source of schema truth, ingestion that produces query-shaped files, permissions governed above the storage layer, and compute — Athena, Redshift, EMR, QuickSight — that rents the same bytes without copying them.

This article is the blueprint for that contract. You will design the S3 zone layout (raw → curated → consumption) with Hive-style partitioning and Parquet/Iceberg formats that cut scan costs by 90%+; ingest with Kinesis Data Streams, Amazon Data Firehose and DMS so files arrive big and partitioned instead of small and scattered; run Glue crawlers, ETL jobs and workflows without letting the catalog drift; query with Athena using partition projection and workgroup cost caps; decide when a workload belongs in Redshift RA3/Serverless instead of the lake and how Spectrum joins both; govern with Lake Formation LF-tags; upgrade hot tables to an Apache Iceberg lakehouse for upserts, deletes and time travel; and orchestrate it with Step Functions or MWAA into QuickSight — each layer with the exact aws CLI, Athena SQL and Terraform, plus the limits and prices that decide designs.

By the end you will be able to defend every byte on the bill. A $3,000/month platform and a $30,000/month swamp use the same services — they differ only in a dozen decisions: file size, format, partition keys, buffer hints, catalog hygiene, scan caps, and where the warehouse boundary sits. This is a reference for making all of them deliberately.

What problem this solves

Analytics on AWS fails in predictable, expensive ways, and almost never because a service broke — it fails because defaults shipped to production: Firehose’s 5 MB buffer produced millions of tiny files; a crawler rewrote a schema at 03:00; an unpartitioned table made every dashboard scan three years of history; bucket policies multiplied until nobody could say who can read PII. Each is invisible on day one and structural by month six — re-partitioning a petabyte is a quarter of work — which is why lake architecture must be decided before the data arrives.

Failure mode What it looks like in production Root cause Where this article fixes it
Scan-cost blowout Athena bill grows faster than data; $5/TB × everything, every query Row formats (JSON/CSV), no partitions, SELECT * S3 foundation · Athena
Small-files swamp Queries spend 30 s planning, seconds reading; S3 request costs spike Default Firehose buffers, per-event writes, high-cardinality dynamic partitioning Ingestion · Glue compaction
Schema drift HIVE_PARTITION_SCHEMA_MISMATCH; jobs break after a crawler run Crawlers with default update policy on inconsistent data Glue
Two sources of truth Warehouse copy and lake copy of the same table disagree ETL copies data instead of sharing the catalog Redshift Spectrum · Iceberg
Permission sprawl 40 bucket policies, cross-account IAM spaghetti, no column control Governing at the storage layer instead of the table layer Lake Formation
Stuck-in-batch “Real-time” dashboard is 24 h stale Nightly-dump ingestion; no stream path Kinesis · Firehose · DMS CDC
Frozen lake Cannot correct or delete records (GDPR/DPDP) without rewriting partitions Plain Hive tables are append-only Iceberg lakehouse

Who hits this: any team past its first Athena query, hardest at the 1–50 TB/month range — big enough that scan costs and file counts matter, small enough that there is no dedicated platform team. The fix is never “add another service”; it is layout, format and governance discipline applied in the right order.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with core S3 (buckets, keys, storage classes), IAM roles and policies, basic SQL, and reading Terraform. Everything else is built up from first principles. If any of those are shaky, fix them first:

You should already know Depth needed Where to get it
S3 storage classes & lifecycle Classes, transitions, minimum durations S3 Storage Classes and Lifecycle and the S3 deep dive
IAM roles, policies, cross-account Read/write role design AWS Organizations & IAM foundations
VPC basics (for endpoints, Redshift, DMS) Subnets, SGs, endpoints VPC, subnets and security groups
OLTP vs analytics workloads Why row stores don’t scan RDS vs DynamoDB vs Aurora
Medallion / zone thinking (optional) Bronze/silver/gold intuition Lakehouse medallion architecture

This article sits at the centre of the Data & Analytics on AWS track: upstream are the storage and account foundations above; downstream sit the specialised deep dives — Apache Iceberg on S3 with Glue for table-format operations, DynamoDB Streams CDC pipelines for NoSQL sources, Kafka as the event backbone on AWS if you outgrow Kinesis, and the enterprise AWS lakehouse reference architecture for the multi-account version. Running Snowflake or BigQuery alongside AWS? The layout principles transfer — compare Snowflake + dbt on AWS and BigQuery’s warehouse model.

Core concepts

Five mental models carry every design decision in this article.

Storage and compute are separated, and the catalog is the hinge. A classic warehouse ships storage, metadata and compute as one product. On AWS they are three independent layers: bytes in S3; schemas, locations and partitions in the Glue Data Catalog (an API-compatible descendant of the Hive metastore); and stateless compute — Athena, Spectrum, Glue Spark, EMR — rented by the query or the hour. Any engine that speaks to the catalog reads the same table without copying it. That is the superpower and the failure mode: if the catalog lies (stale partitions, drifted schema), every engine lies identically.

Files are the database, so file layout is your index. There are no B-trees in a lake. The only “indexes” are: the partition key encoded in the object key (dt=2026-07-01/), which lets engines skip whole directories; the columnar format (Parquet), which lets them skip columns; and Parquet’s internal row-group statistics, which let them skip pages. A lake with the right layout answers a typical BI query by reading 0.1% of the bytes a naive one reads. Nothing you buy later — bigger Redshift, more DPUs — compensates for a wrong layout, because every engine pays the same I/O.

Schema-on-read cuts both ways. Ingestion never fails on schema (raw zone accepts anything), which is why lakes absorb messy sources so well. But it means quality is enforced at read time — by the curated zone’s ETL, not by the storage. The zones exist precisely to move data from “whatever arrived” (raw) to “typed, deduplicated, contracted” (curated) to “modelled for one consumer” (consumption). Skipping the middle zone is how lakes become swamps.

You pay one of two meters: per byte scanned, or per hour provisioned. Athena and Spectrum bill by data touched ($5/TB scanned); Redshift clusters, DMS instances and MWAA bill by wall-clock. The architecture’s job is to put each workload on the cheaper meter: ad-hoc and spiky → scan-metered; hot, repeated, high-concurrency → provisioned. Failed cost models do the opposite — dashboards refreshing every 30 seconds on the $5/TB meter, or a once-a-week query on a 24×7 cluster.

Govern tables, not buckets. IAM and bucket policies see objects and prefixes; they cannot express “analysts may read orders except card_number, and only rows where country = 'IN'”. Lake Formation governs at the database/table/column/row level and is enforced consistently by Athena, Spectrum, Glue and EMR. At more than a handful of teams and tables, bucket-policy governance collapses under its own combinatorics.

The vocabulary in one table

Concept One-line definition Layer Why it matters
Zone (raw/curated/consumption) Contract level of the data in a prefix/bucket S3 Stops the swamp; scopes access
Hive-style partition key=value/ path segments engines can prune S3 layout The coarse index; 10–1000× scan reduction
Parquet Columnar, compressed, splittable file format File Column pruning + predicate pushdown
Apache Iceberg Table format adding ACID, upserts, time travel over Parquet Table format Mutability + safe schema evolution
Glue Data Catalog Regional store of databases/tables/partitions Metadata Single schema truth for all engines
Crawler Job that infers schema/partitions into the catalog Metadata Convenient; drifts if unsupervised
Partition projection Athena computes partitions from rules instead of the catalog Query Kills GetPartitions latency and MSCK
Kinesis Data Streams Sharded, replayable event transport Ingest Real-time path; ordering + fan-out
Amazon Data Firehose Managed delivery: buffer → transform → S3/Redshift Ingest The “file-size governor” of the lake
DMS / CDC Replicates OLTP changes (binlog/WAL) to the lake Ingest Database sources without dump jobs
Redshift RA3 / Serverless Warehouse compute with S3-backed managed storage Warehouse Sub-second repeated BI; separate meter
Spectrum Redshift queries external (catalog) tables on S3 Warehouse↔lake One copy of cold data
Lake Formation / LF-tags Table-level grants via tags, enforced by engines Governance Scales permissions; column/row filters
SPICE QuickSight’s in-memory dataset engine Serving Dashboards stop re-querying the lake

The S3 foundation: zones, layout, formats and lifecycle

Everything downstream — ingestion configs, Glue jobs, Athena bills, Redshift COPY performance — is determined by decisions made here. Get the foundation right and the rest of the platform is configuration; get it wrong and every layer fights it forever.

Zones: the contract levels

Use three primary zones plus two utility areas. Physically, make each zone its own bucket (not a prefix in one bucket): per-bucket encryption defaults, lifecycle rules, replication, access boundaries and cost reporting all become trivial, and a mistaken recursive delete cannot cross zones. Name them with account/region qualifiers since bucket names are global: mycorp-lake-raw-apsouth1-prod.

Zone Who writes Format Schema stance Retention Who reads Typical access control
Raw (bronze) Ingestion services only (Firehose, DMS, batch) As-arrived: JSON/CSV/Parquet from source Schema-on-read; never rejected 1–7 years (compliance), lifecycle to Glacier Glue ETL, replay jobs; humans rarely Engineers + pipeline roles only
Curated (silver) Glue/EMR ETL only Parquet or Iceberg, SNAPPY/ZSTD Enforced, typed, deduplicated, documented 1–3 years hot, then archive Athena, Spectrum, data science LF-tags per domain; column filters on PII
Consumption (gold) Modelling jobs (dbt-style aggregates, marts) Parquet/Iceberg, aggregated Dimensional/metric contracts Rebuildable; 90–400 days QuickSight, Redshift, APIs Broad read via LF-tags
Athena results / scratch Athena, ad-hoc CTAS Whatever the query wrote None 7–30 days auto-expire The author Per-workgroup prefix
Logs / access logs S3, CloudTrail data events Service-native n/a 90–400 days Security Security roles

Two rules make zones real rather than decorative. First, raw is immutable: append-only, byte-for-byte what the source sent — if a bug corrupts curated, you rebuild it from raw; enable S3 Versioning (and Object Lock where regulators care) to protect that undo log. Second, no consumer reads raw: the moment a dashboard points at raw JSON “temporarily”, you have frozen that schema forever and put your most expensive scans on your worst format.

Buckets, prefixes and S3’s real limits

Within a zone bucket, lay out prefixes as source/dataset/partitionkeys.../file:

s3://mycorp-lake-raw-apsouth1-prod/
  pos/orders/ingest_dt=2026-07-06/hr=13/part-0001.gz.parquet
  pos/orders/ingest_dt=2026-07-06/hr=14/...
  clickstream/events/ingest_dt=2026-07-06/hr=13/...
  dms/erp/customers/LOAD00000001.parquet          # DMS full load
  dms/erp/customers/2026/07/06/20260706-131502044.parquet  # DMS CDC

s3://mycorp-lake-curated-apsouth1-prod/
  sales/orders/dt=2026-07-06/part-...-c000.zstd.parquet
  sales/order_items/dt=2026-07-06/...
  customer/profiles_iceberg/data/...              # Iceberg manages its own layout
  customer/profiles_iceberg/metadata/...

The performance numbers that matter: S3 sustains 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD requests per second per partitioned prefix, partitioning automatically as rates grow. Analytics-sized files never hit this; the tiny-file pattern (millions of 200 KB objects) is what turns request rates and per-request prices into a real constraint. Randomised key prefixes have been unnecessary since 2018 — human-readable, sortable layouts like the above are correct.

Three layout rules with teeth: keep one dataset (one schema) per prefix — mixed schemas under one table location break crawlers and confuse every engine; let writers generate unique file names and never overwrite objects in place on Hive-style tables (a reader mid-scan sees torn state — write new files then swap the partition, or use Iceberg, whose commits are atomic); and pair Versioning with a NoncurrentVersionExpiration lifecycle rule, or every compaction rewrite quietly doubles storage.

Partitioning: the coarse index

A partition column turns WHERE dt BETWEEN '2026-07-01' AND '2026-07-07' into “read 7 directories instead of 1,095”. It is the single highest-leverage performance decision in the lake. It also has a failure mode in each direction: too few partitions and every query scans everything; too many (high-cardinality keys, multi-key explosions) and you get the small-files swamp plus slow planning.

Strategy Example path Partitions/year Best for Gotcha
Daily date dt=2026-07-06/ 365 Default for almost everything Backfills rewrite whole days
Hourly dt=2026-07-06/hr=13/ 8,760 High-volume streams (>50 GB/day), hour-scoped SLAs 24× the partitions; compact hours→days after N days
Date + low-cardinality dim dt=.../region=in/ 365 × regions Queries always filter the dim (multi-tenant regions) Only if every query filters it; else it just multiplies partitions
Entity-first tenant=4711/dt=.../ tenants × 365 Strict per-tenant isolation/offboarding 10k tenants × 365 = 3.65M partitions — catalog pain; prefer LF row filters
Iceberg hidden partitioning day(event_ts) transform managed Iceberg tables Consumers don’t need to know the partition column at all
None flat prefix 1 Tiny reference tables (<1 GB) Fine — partitioning 50 MB is pure overhead

Sizing heuristics that survive production: keep each partition ≥ ~100 MB of columnar data (below that, overhead beats pruning — partition by day, not hour, at modest volumes); keep partitions per table in the tens of thousands, not millions (each is a Glue catalog object, and GetPartitions traffic scales with them); and never partition by a high-cardinality column (user_id, order_id, a 1,800-value store_id) — sort order within files plus Parquet statistics do that job.

File formats: where 90% of the money is

Columnar formats win analytics for three mechanical reasons: engines read only referenced columns (column pruning), skip row groups whose min/max statistics exclude the predicate (predicate pushdown), and compress far better because similar values sit together. The same 1 TB of JSON is typically 100–200 GB as SNAPPY Parquet — and a SELECT 4 columns WHERE dt=... might scan 2 GB of it. At Athena’s $5/TB, format choice is the bill.

Format Layout Splittable Compression Schema evolution Typical size vs raw JSON Scan cost behaviour Use in the lake
CSV Row text Yes (uncompressed); gzip = no External (gzip) Positional — fragile ~0.5× gzipped Full-file scans; no pruning Legacy interchange only
JSON Lines Row text gzip = no External Tolerant reads, no contract 1× (verbose) Full-file scans + parse CPU Raw zone as-arrived
Avro Row binary Yes Internal blocks Strong (registry-style) ~0.4× Row-oriented — poor for scans Streaming interchange, Kafka
Parquet Columnar Yes SNAPPY/ZSTD/GZIP per column Add/rename columns (by position in Hive; by ID in Iceberg) ~0.15–0.25× Column pruning + row-group skipping Default for curated/consumption
ORC Columnar Yes ZLIB/ZSTD Similar to Parquet ~0.15–0.25× Comparable; better in some Hive stacks Fine, but Parquet has broader AWS tooling
Iceberg (over Parquet) Table format + manifests Yes Parquet’s Safe: by column ID, plus partition evolution Parquet + ~1% metadata Adds file-level stats pruning Curated tables needing mutation

Compression inside Parquet: SNAPPY (the common default) decompresses fastest; ZSTD is ~20–30% smaller for slightly more CPU and is well supported across Athena/Glue/Firehose — prefer it for storage-heavy tables. GZIP on CSV/JSON is a hidden parallelism killer: non-splittable, one reader per file.

File size matters as much as format. Every object carries fixed costs — a GET request, TLS setup, footer parse, a task-scheduling slot — so 1 TB at 1 MB/file is a million objects and a planning disaster, while the same terabyte at the target 128–512 MB per file is a few thousand objects scanned in parallel. Above ~2 GB is still fine for Parquet (row groups keep it splittable) but bad for gzipped JSON/CSV, which a single reader must decompress end to end.

Storage classes and lifecycle: paying for temperature honestly

Lake data cools fast: yesterday’s partition is hot, last quarter’s is warm, 2023’s exists for the auditor and the once-a-year backfill. S3 prices that curve (us-east-1, indicative; Mumbai ap-south-1 runs ~10% higher on storage):

Class $/GB-month Min duration Min billable size Retrieval fee Lake fit
Standard $0.023 none none none Landing + last 30–90 days of curated
Intelligent-Tiering $0.023→$0.0125→$0.004 auto none 128 KB for auto-tiering none (monitoring $0.0025/1k objects) Curated with unpredictable access — the safe default
Standard-IA $0.0125 30 days 128 KB $0.01/GB Known-cool partitions ≥ 128 KB files
One Zone-IA $0.01 30 days 128 KB $0.01/GB Rebuildable derived data only (single-AZ)
Glacier Instant Retrieval $0.004 90 days 128 KB $0.03/GB Old partitions still queried occasionally (ms access)
Glacier Flexible Retrieval $0.0036 90 days per-GB + minutes–hours restore Compliance copies; not Athena-queryable in place
Glacier Deep Archive $0.00099 180 days per-GB + hours restore 7-year raw retention

Design notes that bite: Athena and Spectrum read Standard, IA and Glacier Instant Retrieval directly, but objects in Glacier Flexible/Deep Archive fail or are skipped until restored — never archive a partition analysts might query without a restore runbook. And the tiny-file swamp poisons lifecycle economics too: transitions are billed per object (~$0.01–0.05 per 1,000 depending on target), and the 128 KB minimums mean small objects don’t even get cheaper. Compact first, then tier.

# Lifecycle: raw → IA at 30d → Glacier IR at 90d → Deep Archive at 400d; expire old versions
aws s3api put-bucket-lifecycle-configuration \
  --bucket mycorp-lake-raw-apsouth1-prod \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "raw-cooling", "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "Transitions": [
        {"Days": 30,  "StorageClass": "STANDARD_IA"},
        {"Days": 90,  "StorageClass": "GLACIER_IR"},
        {"Days": 400, "StorageClass": "DEEP_ARCHIVE"}
      ],
      "NoncurrentVersionExpiration": {"NoncurrentDays": 30},
      "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
    }]
  }'
resource "aws_s3_bucket" "curated" {
  bucket = "mycorp-lake-curated-apsouth1-prod"
}

resource "aws_s3_bucket_server_side_encryption_configuration" "curated" {
  bucket = aws_s3_bucket.curated.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.lake.arn
    }
    bucket_key_enabled = true   # cuts KMS request costs ~99% on high-object-count buckets
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "curated" {
  bucket = aws_s3_bucket.curated.id
  rule {
    id     = "curated-cooling"
    status = "Enabled"
    filter {}                                    # entire bucket
    transition { days = 60  storage_class = "INTELLIGENT_TIERING" }
    abort_incomplete_multipart_upload { days_after_initiation = 7 }
  }
}

The bucket_key_enabled line matters: with SSE-KMS and millions of objects, S3 Bucket Keys collapse per-object KMS calls into per-bucket-key calls — the difference between a negligible KMS line and thousands of dollars.

Ingestion: Kinesis Data Streams, Firehose, DMS and batch

Ingestion’s job is not “get data into S3” — anything can do that. Its job is to land data already query-shaped: correctly zoned, partitioned by event time or arrival time, in large files, with failures quarantined instead of dropped. Choose the tool by the shape of the source:

Source shape Tool Latency to S3 Scaling model Delivery semantics When it’s wrong
App/clickstream events, IoT Kinesis Data Streams → Firehose ~60 s–15 min (buffer) Shards or on-demand At-least-once (dedupe downstream) You need Kafka-grade fan-out/ecosystem — use MSK
Events, no replay/fan-out needed Firehose direct PUT Buffer interval Fully managed At-least-once You later need a second real-time consumer — insert KDS from day one if in doubt
OLTP databases (MySQL/Postgres/Oracle/SQL Server) DMS full load + CDC Seconds–minutes Replication instance or DMS Serverless At-least-once, per-table change order Aurora→Redshift only? Consider zero-ETL instead
DynamoDB tables DynamoDB Streams/Kinesis + Firehose, or export to S3 Stream: seconds; export: batch Managed At-least-once See the DynamoDB Streams CDC pipeline
SaaS APIs (Salesforce, SAP, GA) AppFlow Scheduled/event Managed Per-flow Complex transforms — land raw, transform in Glue
Files from on-prem/NFS DataSync; Transfer Family (SFTP) Scheduled Managed agents Verified copy One-time bulk ≥ tens of TB — Snowball Edge
Existing nightly extracts aws s3 cp/sync multipart Batch Your scheduler Your problem Fine — just land into the raw layout, not ad-hoc paths

Two cross-cutting rules. Idempotence: every path here is at-least-once, so curated-zone ETL must deduplicate (event ID + event time, or Iceberg MERGE). Event time vs arrival time: raw partitions by arrival (ingest_dt, all ingestion reliably knows); curated re-partitions by event time (dt from the payload, what analysts filter on). Confusing the two produces silently wrong “daily” numbers and lost late events.

Kinesis Data Streams: the replayable front door

Kinesis Data Streams (KDS) is the durable, ordered, replayable transport you put in front of Firehose when more than one consumer needs the events (real-time anomaly detection and lake delivery), or when you need replay. Capacity is either provisioned (you manage shards) or on-demand (AWS manages them; you pay per GB).

Limit / property Provisioned On-demand
Write capacity 1 MB/s or 1,000 records/s per shard Stream scales to ~2× previous peak; default cap 200 MB/s (raisable)
Read per shard 2 MB/s shared across standard consumers Same, managed
Enhanced fan-out (EFO) +2 MB/s per consumer per shard, push; up to 20 registered consumers Same
Max record size 1 MB 1 MB
PutRecords batch 500 records, ≤ 5 MB total Same
GetRecords ≤ 10,000 records / 10 MB per call; 5 calls/s per shard n/a (managed)
Retention 24 h default → 7 d (extended) → 365 d (long-term, extra $/GB-mo) Same
Resharding Split/merge operations, brief per-shard pause Automatic
Pricing (us-east-1, indicative) $0.015/shard-hr + $0.014 per 1M PUT payload units (25 KB units) $0.04/stream-hr + $0.08/GB in + $0.04/GB out

The economics flip on utilisation: well-utilised provisioned shards cost several times less per GB than on-demand, so steady volume → provisioned; spiky or unknown → on-demand. The provisioned-mode trap is the hot shard: records route by MD5(partition key), so keying by store_id when one store does 30% of volume throttles one shard while nineteen idle — WriteProvisionedThroughputExceeded fires while average utilisation looks healthy (confirm with enhanced shard-level metrics). Key by a high-cardinality value unless you genuinely need per-key ordering.

# On-demand stream, 7-day retention, CMK encryption
aws kinesis create-stream --stream-name clickstream-prod \
  --stream-mode-details StreamMode=ON_DEMAND
aws kinesis increase-stream-retention-period \
  --stream-name clickstream-prod --retention-period-hours 168
aws kinesis start-stream-encryption --stream-name clickstream-prod \
  --encryption-type KMS --key-id alias/lake-ingest
resource "aws_kinesis_stream" "clickstream" {
  name             = "clickstream-prod"
  retention_period = 168
  encryption_type  = "KMS"
  kms_key_id       = aws_kms_key.lake.arn
  stream_mode_details { stream_mode = "ON_DEMAND" }
}

Amazon Data Firehose: the file-size governor

Firehose (now Amazon Data Firehose) is the zero-administration delivery service: it reads from KDS or direct PutRecord, optionally transforms batches with a Lambda, optionally converts JSON to Parquet/ORC against a Glue table schema, buffers, and writes S3 objects under your prefix pattern, quarantining failures to an error prefix. It is the lake’s file-size knob — every row below is a design decision, not a default to accept:

Setting Values Default Set it to Why / trade-off
Buffer size (S3 destination) 1–128 MB 5 MB 128 MB Big files; the 5 MB default is the #1 swamp cause
Buffer interval 60–900 s 300 s 600–900 s for lakes Freshness vs file size; flush fires on whichever hits first
Record format conversion JSON → Parquet/ORC via Glue schema off On for event streams Lands columnar directly; $0.018/GB; requires a Glue table holding the schema
Compression GZIP/SNAPPY/ZSTD none ZSTD (inside Parquet when conversion on) Smaller scans downstream
Dynamic partitioning jq-style keys from payload → prefix off Only low-cardinality keys (dt, hr from event time) Each active partition buffers separately (min buffer 64 MB); high-cardinality keys explode file counts; +$0.02/GB and per-object fees
S3 prefix template Static + !{timestamp:...} + !{partitionKeyFromQuery:...} YYYY/MM/dd/HH UTC dataset/dt=!{timestamp:yyyy-MM-dd}/hr=!{timestamp:HH}/ Hive-style so engines discover partitions
Error output prefix any none errors/!{firehose:error-output-type}/dt=... Failed conversions land here instead of vanishing — alarm on this prefix
Transform Lambda per-batch function off Light normalisation only (flatten, PII hash) Heavy logic belongs in Glue
Max record / batch 1,000 KiB record; PutRecordBatch ≤ 500 records/4 MiB Oversized events: store payload in S3, send a pointer
Pricing (us-east-1, indicative) ~$0.029/GB ingested (first 500 TB tier) + $0.018/GB conversion + $0.02/GB dynamic partitioning

The buffer maths: at 2 MB/s a 128 MB buffer fills in ~64 s — big files and ~1-minute freshness; at 50 KB/s the 900 s interval fires first → ~45 MB files, still fine. At 5 MB/60 s defaults with dynamic partitioning across a 1,800-value key: thousands of sub-MB objects per hour, forever. That arithmetic is behind most “Athena is slow” tickets.

resource "aws_kinesis_firehose_delivery_stream" "clickstream_to_raw" {
  name        = "clickstream-to-raw"
  destination = "extended_s3"

  kinesis_source_configuration {
    kinesis_stream_arn = aws_kinesis_stream.clickstream.arn
    role_arn           = aws_iam_role.firehose.arn
  }

  extended_s3_configuration {
    role_arn            = aws_iam_role.firehose.arn
    bucket_arn          = aws_s3_bucket.raw.arn
    prefix              = "clickstream/events/dt=!{timestamp:yyyy-MM-dd}/hr=!{timestamp:HH}/"
    error_output_prefix = "errors/clickstream/!{firehose:error-output-type}/dt=!{timestamp:yyyy-MM-dd}/"
    buffering_size      = 128   # MB
    buffering_interval  = 900   # seconds

    data_format_conversion_configuration {
      input_format_configuration  { deserializer { open_x_json_ser_de {} } }
      output_format_configuration { serializer   { parquet_ser_de { compression = "ZSTD" } } }
      schema_configuration {
        database_name = aws_glue_catalog_database.raw.name
        table_name    = "clickstream_events"
        role_arn      = aws_iam_role.firehose.arn
      }
    }
  }
}

DMS: databases into the lake without dump scripts

AWS DMS replicates relational sources into S3 using the engine’s change log — MySQL/Aurora binlog (must be binlog_format=ROW, with retention covering your longest outage), PostgreSQL logical replication, Oracle redo, SQL Server CDC. Of its three task modes — full load (one-time bulk seed, LOAD000... files per table), CDC only (change files carrying an Op column of I/U/D from a checkpoint), and full load + CDC — the last is the default for lake sync; validate row counts after cutover anyway. If the only consumer is Redshift BI, managed zero-ETL (Aurora/RDS→Redshift) skips the lake copy entirely.

The S3 target endpoint settings decide whether DMS emits lake-friendly files or a CSV mess. Set them explicitly:

S3 target setting Default Set to Effect
DataFormat csv parquet Columnar landing, no conversion job
ParquetVersion parquet-2-0 Modern encodings
CdcMaxBatchInterval ~60 s 300–900 s Fewer, bigger CDC files
CdcMinFileSize ~32 MB 64–128 MB Write when size or interval hits
TimestampColumnName off dms_ts Commit timestamp — your ordering key downstream
IncludeOpForFullLoad false true Full-load rows also carry Op='I' — uniform schema
DatePartitionEnabled false true CDC lands under date prefixes for pruning

Downstream, curated must apply the change stream: a periodic Glue job MERGEs the latest change per primary key (ordered by dms_ts) into an Iceberg table, turning a change log into a queryable current-state table — the same pattern as Debezium/Kafka CDC into a warehouse with DMS in Debezium’s role. Size the replication instance by change rate, not table size: a small dms.t3.medium (roughly $60/month) covers modest OLTP change volumes; C/R classes and Multi-AZ for busy sources, or DMS Serverless to stop guessing.

Glue: the catalog and the ETL layer

AWS Glue is three products sharing a console: the Data Catalog (metadata everything depends on), crawlers (schema inference), and ETL jobs (serverless Spark, plus workflows and Data Quality). Trust them differently: the catalog is sacred, jobs are workhorses, crawlers are useful but must be kept on a leash.

The Data Catalog: one schema truth

The catalog holds databases → tables → partitions. Each table stores location, format, SerDe, columns and partition keys; each partition is its own catalog object with its own location and — dangerously — its own copy of the schema. Athena, Redshift Spectrum, EMR, Glue jobs and Lake Formation all resolve tables here. That is the point: change schema once, every engine sees it.

Catalog fact Value Design consequence
Scope Regional, per account Share across accounts via Lake Formation/RAM, never by copying
Free tier First 1M objects stored; 1M requests/month Small lakes: effectively free
Beyond free $1.00 per 100,000 objects/month; $1.00 per 1M requests Million-partition tables carry a real metadata cost
Partitions are objects Every partition counts toward storage/requests Compact hourly partitions to daily after N days
Partition indexes B-tree-style index on partition keys Turns filtered GetPartitions from scan-all into lookup — create on dt for every large table
Column statistics Optional per-table stats Athena v3 CBO uses them — generate on hot tables
Cross-account Catalog resource policy or LF grants Prefer LF; resource policies sprawl
aws glue create-database --database-input '{"Name":"curated_sales","Description":"Curated sales domain"}'

# Partition index on the big table (create alongside the table, not after year 2)
aws glue create-partition-index \
  --database-name curated_sales --table-name orders \
  --partition-index Keys=dt,IndexName=dt_idx

Crawlers: schema inference with a leash

A crawler walks a prefix, samples files, infers schema and writes tables/partitions to the catalog. Stable datasets often don’t need one — explicit CREATE EXTERNAL TABLE plus projection is more deterministic; crawlers earn their keep on unknown or evolving sources (DMS landing areas, third-party drops). The defaults are what cause “the crawler broke prod”:

Crawler setting Options Default Production setting Why
SchemaChangePolicy.UpdateBehavior UPDATE_IN_DATABASE / LOG UPDATE LOG on curated; UPDATE only on raw landing Stops silent schema rewrites under consumers
SchemaChangePolicy.DeleteBehavior DELETE_FROM_DATABASE / LOG / DEPRECATE_IN_DATABASE DEPRECATE LOG A temporarily-empty prefix must not drop tables
RecrawlPolicy CRAWL_EVERYTHING / CRAWL_NEW_FOLDERS_ONLY / S3-event mode EVERYTHING CRAWL_NEW_FOLDERS_ONLY or event mode Full recrawls of big lakes are slow and billed
Table grouping TableGroupingPolicy, TableLevelConfiguration (path depth) heuristic Set table level explicitly Prevents “one table per day-prefix” explosions
Exclusions glob patterns none **/_temporary/**, **/errors/**, **/_spark_metadata/** Job droppings otherwise become “tables”
Schedule cron / on-demand / event on-demand Trigger after the ETL that changes layout Crawl when something changed, not blind cron
Cost $0.44/DPU-hr, 10-min minimum per run Don’t cron every 5 minutes Minimums alone on a 5-min cron ≈ $21/day

The classic drift incident: a source starts sending customer_id as a number; the crawler (UPDATE mode) rewrites the column type; older partitions still carry the old type; Athena throws HIVE_PARTITION_SCHEMA_MISMATCH. Prevention: LOG mode plus change-controlled ALTER TABLE on anything consumers query — and Iceberg (schema versioned by column ID) where evolution is routine.

ETL jobs: serverless Spark, sized honestly

Glue jobs run Spark (or a lightweight Python shell) on managed DPUs (1 DPU = 4 vCPU + 16 GB) at $0.44/DPU-hour, billed per second with a 1-minute minimum (Glue 2.0+). Glue 4.0 = Spark 3.3; Glue 5.0 = Spark 3.5 / Python 3.11. Use 4.0+ for native --datalake-formats iceberg,delta,hudi.

Worker type vCPU / memory DPU Use for Note
G.025X 2 / 4 GB 0.25 Low-volume streaming jobs Streaming only
G.1X 4 / 16 GB 1 Default batch transform/compaction Spark jobs need ≥ 2 workers
G.2X 8 / 32 GB 2 Wide shuffles, memory-heavy joins First fix for OOM
G.4X 16 / 64 GB 4 Skewed joins, very wide rows Glue 3.0+
G.8X 32 / 128 GB 8 Heavy single-stage crunching Profile before paying for it
Python shell small 0.0625 or 1 API calls, file moves, small pandas Pennies per run
Job option Values Default Production guidance
Job bookmarks enable / disable / pause disabled Enable for incremental S3/JDBC reads — Glue tracks processed objects/keys so reruns don’t reprocess history
Flex execution standard / flex standard Flex = $0.29/DPU-hr on spare capacity for non-urgent batch; start may be delayed
Auto scaling on/off (Glue 3.0+) off On — Glue right-sizes workers up to your max
Timeout minutes 2,880 (48 h) Set ~3× expected runtime; a hung 10-DPU job left for 48 h is ~$211
Retries 0–10 0 1 for idempotent jobs + alarm; never mask systematic failures
Max concurrent runs n 1 Raise for parallel per-partition backfills
--datalake-formats iceberg / hudi / delta Required for table-format writes

The bread-and-butter lake job — compact small raw files into sized, partitioned curated Parquet:

# Glue 5.0 job: raw clickstream → curated Parquet, deduplicated, partitioned by event time
import sys
from awsglue.context import GlueContext
from awsglue.job import Job
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext

args = getResolvedOptions(sys.argv, ["JOB_NAME", "dt"])
gc = GlueContext(SparkContext())
job = Job(gc); job.init(args["JOB_NAME"], args)

src = gc.create_dynamic_frame.from_catalog(
    database="raw", table_name="clickstream_events",
    push_down_predicate=f"dt='{args['dt']}'",              # partition pruning at read
    additional_options={"groupFiles": "inPartition",        # coalesce small files on read
                        "groupSize": "134217728"})           # 128 MB groups

df = src.toDF().dropDuplicates(["event_id"])
(df.repartition("event_dt")
   .sortWithinPartitions("session_id")                       # co-locate values → row-group stats work
   .write.mode("overwrite")
   .option("compression", "zstd")
   .partitionBy("event_dt")
   .parquet("s3://mycorp-lake-curated-apsouth1-prod/clickstream/events/"))
job.commit()
aws glue start-job-run --job-name curate-clickstream \
  --arguments '{"--dt":"2026-07-06"}' \
  --worker-type G.1X --number-of-workers 10

Workflows, triggers and Data Quality

Glue workflows chain crawlers and jobs with triggers — free, and adequate for pipelines living entirely inside Glue; anything spanning services belongs in Step Functions (compared later). Glue Data Quality evaluates DQDL rulesets (RowCount > 1000, Completeness "order_id" > 0.99) against tables or inline in jobs at $0.44/DPU-hr — put a ruleset at the raw→curated boundary so garbage stops at the zone line instead of reaching dashboards.

Athena: the lake query engine

Athena is serverless, Trino-based (engine v3) SQL over catalog tables. No cluster to size; you pay $5 per TB scanned, 10 MB minimum per query (us-east-1). DDL is free; failed queries are free; cancelled queries bill for bytes already scanned. That pricing model is the whole game — every optimisation below cuts bytes scanned, and the same moves make queries faster because scan time tracks scan bytes.

Athena fact / limit Value Consequence
Price $5/TB scanned; 10 MB minimum/query Columnar + partitioning are money, not style
DDL / metadata Free CREATE TABLE, MSCK, SHOW PARTITIONS cost nothing
DML timeout 30 min default (raisable via quota) Multi-hour SQL belongs in Glue/EMR
Concurrency Soft account quotas (~20–25 running DML typical; raisable) Bursty BI needs result reuse, SPICE or provisioned capacity
Query text ≤ 262,144 bytes Generated IN (...) lists can hit this
CTAS/INSERT partitions ≤ 100 per statement Backfills loop INSERT INTO in ≤100-partition chunks
Result reuse Per-query opt-in, ≤ 60 min age Identical dashboard SQL scans once
Provisioned capacity Optional; min 24 DPUs, ~$0.30/DPU-hr Dedicated capacity for heavy concurrent BI
Federated queries Lambda connectors (DynamoDB, RDS, CloudWatch…) $5/TB on connector-scanned bytes + Lambda cost

Partition management: from MSCK to projection

Athena prunes only partitions it knows about. The four ways to keep it informed, in ascending production-worthiness for time-series layouts: MSCK REPAIR TABLE (walks the whole prefix tree — slow, dev-only), explicit ALTER TABLE ADD PARTITION from ingestion hooks (instant, free), a scheduled crawler (fine when it exists anyway; 10-minute billing minimum per run), and partition projection — the production default for date/hour/enum layouts. Projection replaces per-partition catalog objects with rules evaluated at plan time: new data is queryable the second the object lands, GetPartitions throttling disappears, and effectively-million-partition tables plan in milliseconds. Configuration lives in TBLPROPERTIES:

CREATE EXTERNAL TABLE curated_sales.orders (
  order_id      string,
  customer_id   string,
  store_id      string,
  amount        decimal(12,2),
  event_ts      timestamp
)
PARTITIONED BY (dt string)
STORED AS PARQUET
LOCATION 's3://mycorp-lake-curated-apsouth1-prod/sales/orders/'
TBLPROPERTIES (
  'projection.enabled'          = 'true',
  'projection.dt.type'          = 'date',
  'projection.dt.range'         = '2024-01-01,NOW',
  'projection.dt.format'        = 'yyyy-MM-dd',
  'projection.dt.interval'      = '1',
  'projection.dt.interval.unit' = 'DAYS',
  'storage.location.template'   = 's3://mycorp-lake-curated-apsouth1-prod/sales/orders/dt=${dt}/'
);
Projection property Types / values Notes
projection.<col>.type date, integer, enum, injected injected = caller must supply the value in WHERE (arbitrary IDs)
projection.<col>.range 2024-01-01,NOW / 1,999 NOW keeps date ranges open-ended
.format / .interval / .interval.unit date pattern + step Hourly: format yyyy-MM-dd-HH, interval 1 HOURS
projection.<col>.values enum list e.g. in,ae,sg
projection.<col>.digits zero-padding width store_id as 0042
storage.location.template path with ${col} Required when layout isn’t col=value/ Hive style

Two caveats: projection is an Athena-side feature — Redshift Spectrum and Glue Spark still need real catalog partitions (or Iceberg, which removes this problem class entirely); and a query with no filter on a projected column enumerates the entire projected range, so keep ranges honest.

CTAS and INSERT: transformation inside Athena

CTAS (CREATE TABLE AS SELECT) writes query results as a new table — the cheapest format-conversion and repartitioning tool, within one limit: 100 partitions max per CTAS or INSERT INTO statement. A three-year daily backfill is one CTAS for the first 100 days plus a scripted INSERT INTO loop (or a Glue job for very large rewrites).

CREATE TABLE curated_sales.orders_parquet
WITH (
  format = 'PARQUET',
  write_compression = 'ZSTD',
  external_location = 's3://mycorp-lake-curated-apsouth1-prod/sales/orders_v2/',
  partitioned_by = ARRAY['dt'],
  bucketed_by = ARRAY['customer_id'], bucket_count = 16
) AS
SELECT order_id, customer_id, store_id,
       CAST(amount AS decimal(12,2))                          AS amount,
       CAST(from_iso8601_timestamp(ts) AS timestamp)          AS event_ts,
       date_format(from_iso8601_timestamp(ts), '%Y-%m-%d')    AS dt
FROM raw.orders_json
WHERE dt BETWEEN '2026-03-29' AND '2026-07-06';   -- ≤ 100 partition values

Workgroups: the cost circuit-breakers

Workgroups separate teams, result locations and — critically — enforce scan limits. Run one per team/tool with limits on; this converts “someone SELECT *'d three years of events” from a $900 surprise into a cancelled query.

Workgroup control What it does Recommended
BytesScannedCutoffPerQuery Cancels any query past N bytes 1 TB analysts; 100 GB dashboards
Result location + lifecycle Governed results prefix Per-workgroup prefix, 30-day expiry
EnforceWorkGroupConfiguration Users cannot override settings true
Result encryption SSE-KMS on results Lake CMK
Engine version v3 Pin v3
Result reuse Allow cached results On for BI workgroups
CloudWatch metrics DataScannedInBytes per workgroup Alarm at 80% of monthly scan budget
aws athena create-work-group --name analysts \
  --configuration 'ResultConfiguration={OutputLocation=s3://mycorp-lake-scratch-apsouth1-prod/athena/analysts/},EnforceWorkGroupConfiguration=true,BytesScannedCutoffPerQuery=1099511627776,EngineVersion={SelectedEngineVersion=Athena engine version 3}'

What actually cuts bytes scanned

Technique Typical scan reduction Mechanism Caveat
JSON → Parquet 5–10× Columnar + compression The single biggest lever
Partition pruning on dt 10–1,000× Reads only matching prefixes Predicate must hit the raw partition value — wrapping it in a function (date(dt)=...) defeats pruning
Naming columns instead of SELECT * 2–20× on wide tables Column pruning BI tools default to * — fix the dataset definition
ZSTD over SNAPPY ~1.2–1.3× Fewer bytes on disk Slightly more CPU
Sorting within files 2–10× on selective filters Row-group min/max skipping Sort by the most-filtered column at write
Bucketing Point lookups/joins Hash-placed files Bucket count fixed at write; rewrite to change
LIMIT ~none for aggregates Scan precedes limit Not a cost control
Result reuse 100% on repeats 60-min cache Identical query text only

Redshift: when the lake needs a warehouse

Athena’s per-scan model is unbeatable for ad-hoc and scheduled batch SQL — and wrong for a 200-user BI portal issuing the same joins every 30 seconds with a sub-second SLA. That workload wants pre-loaded, cached, materialized-view-accelerated compute: Redshift. The boundary is a workload decision, not a religion:

Signal Stay in the lake (Athena) Move the hot slice to Redshift
Query pattern Ad-hoc, exploratory, scheduled batch Repeated dashboard queries, known joins
Latency SLA Seconds are fine Sub-second to ~2 s p95
Concurrency Dozens, bursty Hundreds, sustained
Data touched Occasional full-history scans Hot 3–18 months, constantly
Mutation Append + periodic MERGE Frequent updates, SCD logic, stored procs
Cost meter $/TB wins at low query frequency $/hour wins at high frequency
Team skills SQL only SQL + warehouse ops (or Serverless to reduce ops)

The hybrid is the point: one copy of truth in the lake, only hot marts loaded into Redshift, and Redshift Spectrum joining local tables to lake tables through the same Glue catalog for cold history — orders_hot local, older years external, one UNION ALL view over both.

RA3 and Serverless: compute with managed storage

RA3 nodes decouple compute from Redshift Managed Storage (RMS): data lives in S3-backed managed storage (~$0.024/GB-month) with hot blocks cached on local NVMe, so you size nodes for query throughput, not disk. (Legacy DC2 tied storage to nodes; new designs should not start there.)

Option vCPU / RAM Managed storage ceiling On-demand (us-east-1, indicative) Fit
ra3.large 2 / 16 GiB 8 TB/node ~$0.54/hr Entry provisioned cluster
ra3.xlplus 4 / 32 GiB 32 TB/node ~$1.09/hr Small steady BI
ra3.4xlarge 12 / 96 GiB 128 TB/node ~$3.26/hr Mid-size clusters
ra3.16xlarge 48 / 384 GiB 128 TB/node ~$13.04/hr Large clusters (up to ~128 nodes)
Serverless RPU-based (base 8–512) RMS ~$0.36/RPU-hr, per-second, 60-s min Spiky/part-time warehousing; zero idle cost
Reserved (1–3 yr) Up to ~60% off Only after the workload proves steady

Serverless changes the economics for part-time warehouses: an 8-RPU workgroup active 6 h/day ≈ 8 × $0.36 × 180 h ≈ $520/month, $0 when idle — versus ~$793/month for the smallest always-on xlplus node; steady 24×7 clusters flip the other way once reserved pricing applies. The operational levers that matter: concurrency scaling (transient clusters absorb queue spikes; one free hour accrues per day), auto WLM with query priorities, and data sharing to expose live tables to other clusters/workgroups without copies.

Spectrum, COPY and modeling: how data reaches and serves

Data reaches Redshift four ways: Spectrum external tables (the cluster queries S3 through the Glue catalog live — $5/TB scanned on provisioned clusters, included in Serverless RPUs — for cold history and rarely-joined dimensions), COPY from S3 (parallel bulk load of hot marts on a batch cadence, costing only cluster time), zero-ETL (managed near-real-time CDC from Aurora/RDS/DynamoDB, no pipeline to build), and streaming ingestion (a materialized view directly over Kinesis/MSK for seconds-fresh operational dashboards).

-- The lake, mounted: external schema over the shared Glue catalog
CREATE EXTERNAL SCHEMA lake
FROM DATA CATALOG DATABASE 'curated_sales'
IAM_ROLE 'arn:aws:iam::111122223333:role/redshift-lake-read';

-- Hot mart loaded from curated Parquet (COPY reads Parquet natively)
COPY mart.orders_hot
FROM 's3://mycorp-lake-curated-apsouth1-prod/sales/orders/dt=2026-07-06/'
IAM_ROLE 'arn:aws:iam::111122223333:role/redshift-lake-read'
FORMAT AS PARQUET;

-- One seamless view: hot local + cold lake
CREATE VIEW analytics.orders_all AS
SELECT * FROM mart.orders_hot
UNION ALL
SELECT * FROM lake.orders WHERE dt < '2026-01-01'
WITH NO SCHEMA BINDING;

-- Repeated dashboard aggregate → materialized view, auto-maintained
CREATE MATERIALIZED VIEW mart.daily_store_sales
AUTO REFRESH YES AS
SELECT store_id, dt, COUNT(*) AS orders, SUM(amount) AS revenue
FROM mart.orders_hot GROUP BY store_id, dt;

COPY performance rules: point it at a prefix of many similar-sized files (~100–250 MB compressed, count a multiple of the cluster’s slice count — never one giant single-slice file). Modeling on RA3: DISTSTYLE AUTO/SORTKEY AUTO are the right defaults; override with DISTKEY on the dominant join key and a date-led compound sort key only where AUTO hasn’t converged (interleaved sort keys are legacy — avoid). Materialized views with auto refresh, plus Redshift’s automated MVs, routinely take dashboard p95 from seconds to tens of milliseconds — the answer to “we re-aggregate the same table 40,000 times a day”.

Lake Formation governance and the Iceberg lakehouse

Lake Formation: permissions at the table layer

Without Lake Formation (LF), lake access control means IAM policies naming S3 prefixes and Glue APIs — workable for one team, unmanageable at ten. LF moves the grant to where analysts think: database, table, column, row. You register S3 locations, and integrated engines (Athena, Spectrum, Glue, EMR, QuickSight) enforce LF grants by vending scoped temporary credentials — callers never hold S3 permissions at all. LF permissions cost nothing extra.

Aspect IAM/bucket-policy governance Lake Formation governance
Grant unit Bucket, prefix, object, API action Catalog, database, table, column, row, cell
PII handling Separate buckets/copies per audience Column exclusion + row/cell data filters, one copy
Cross-account Bucket policies + role trust spaghetti LF grants shared via AWS RAM
Auditability CloudTrail S3/Glue events, hard to interpret Grants readable as “role X → SELECT on table Y”
Scale behaviour Policies × teams × datasets — combinatorial LF-tags: policies × tags — linear
Migration risk IAMAllowedPrincipals default means LF enforces nothing until revoked

That last row is the gotcha that defines every LF rollout. For backwards compatibility, new catalogs grant a virtual principal IAMAllowedPrincipals full access to everything — IAM alone still decides, and LF grants are decorative until you revoke it and clear the “use only IAM access control” defaults. Do it database-by-database (hybrid access mode exists precisely for this), in the order that works: register locations → create LF-tags → tag resources → grant roles by tag → revoke IAMAllowedPrincipals on the pilot database → verify with a canary principal → repeat.

# Register the curated bucket under LF (role LF uses to vend credentials)
aws lakeformation register-resource \
  --resource-arn arn:aws:s3:::mycorp-lake-curated-apsouth1-prod \
  --role-arn arn:aws:iam::111122223333:role/lf-register-role

# Tag-based access: define once, tag resources, grant by tag
aws lakeformation create-lf-tag --tag-key domain --tag-values sales,customer,ops
aws lakeformation create-lf-tag --tag-key sensitivity --tag-values public,internal,pii

aws lakeformation add-lf-tags-to-resource \
  --resource '{"Table":{"DatabaseName":"curated_sales","Name":"orders"}}' \
  --lf-tags '[{"TagKey":"domain","TagValues":["sales"]},{"TagKey":"sensitivity","TagValues":["internal"]}]'

# Analysts: SELECT on everything tagged domain=sales AND sensitivity=internal
aws lakeformation grant-permissions \
  --principal DataLakePrincipalIdentifier=arn:aws:iam::111122223333:role/analyst-sales \
  --permissions SELECT \
  --resource '{"LFTagPolicy":{"ResourceType":"TABLE","Expression":[
      {"TagKey":"domain","TagValues":["sales"]},
      {"TagKey":"sensitivity","TagValues":["internal"]}]}}'

LF-tags invert the grant model: instead of role × table (multiplicative growth), you tag tables once and grant role × tag-expression (linear). A new table tagged domain=sales is instantly visible to every principal holding the sales grant. Data filters add row/cell security on top — country = 'IN' with card_number excluded gives a partner team one governed slice of one copy. Design the tag ontology before granting anything:

LF-tag Values Applied to Grant pattern
domain sales, customer, ops, finance Databases, tables Domain analyst roles get their domain
layer raw, curated, consumption Databases Engineers: all; analysts: curated+consumption
sensitivity public, internal, pii Tables, columns pii granted only to compliance-approved roles
shared true, false Tables Cross-account RAM shares select on shared=true

Iceberg: tables that behave like tables

Plain Hive-style Parquet tables are append-only directories: no transactional updates or deletes, positional schema changes, and concurrent writers can corrupt each other. Apache Iceberg fixes this with a metadata layer — a versioned tree (metadata.json → manifest list → manifests → data files) where every commit atomically produces a new snapshot. Athena engine v3 reads and writes Iceberg; Glue, EMR Spark and Firehose speak it natively; the Glue catalog stores it as a first-class table type.

Capability Hive-style Parquet table Iceberg table
Mutations Rewrite whole partitions manually MERGE INTO, UPDATE, DELETE (ACID)
Concurrent writers Last write wins / corruption risk Optimistic concurrency, atomic commits
Schema evolution By column position — renames break By column ID — add/rename/drop safely
Partition scheme Fixed at creation; layout = contract Hidden partitioning via transforms (day(ts), bucket(16, id)); can evolve without rewriting
Partition discovery Catalog entries / projection needed In table metadata — no GetPartitions, no MSCK, no projection
Time travel None FOR TIMESTAMP AS OF / FOR VERSION AS OF snapshots
File-level pruning Row-group stats only Manifest column stats prune files before opening them
Cost of ownership None beyond layout discipline Maintenance: compaction, snapshot expiry, orphan cleanup

Adopt Iceberg for CDC-fed current-state tables (MERGE per batch), GDPR/DPDP erasure (DELETE WHERE customer_id = ... instead of rewriting partitions), frequently-corrected facts, concurrent writers, and tables whose schema will evolve. Skip it for immutable append-only event history with a stable schema — plain partitioned Parquet plus projection is simpler and maintenance-free.

-- Iceberg in Athena: create, upsert CDC, time-travel, maintain
CREATE TABLE curated_customer.profiles (
  customer_id string, email string, tier string,
  country string, updated_at timestamp)
PARTITIONED BY (bucket(16, customer_id))
LOCATION 's3://mycorp-lake-curated-apsouth1-prod/customer/profiles/'
TBLPROPERTIES ('table_type'='ICEBERG', 'format'='PARQUET',
               'vacuum_max_snapshot_age_seconds'='432000');

MERGE INTO curated_customer.profiles t
USING staging.profile_changes s ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op = 'D' THEN DELETE
WHEN MATCHED THEN UPDATE SET email = s.email, tier = s.tier,
                             country = s.country, updated_at = s.dms_ts
WHEN NOT MATCHED THEN INSERT (customer_id, email, tier, country, updated_at)
                      VALUES (s.customer_id, s.email, s.tier, s.country, s.dms_ts);

SELECT * FROM curated_customer.profiles
FOR TIMESTAMP AS OF TIMESTAMP '2026-07-01 00:00:00 UTC'
WHERE customer_id = 'C-10442';

Iceberg’s tax is maintenance: every MERGE writes small delta files and a new snapshot; unmaintained tables accumulate thousands of small files and stale snapshots until queries crawl and storage doubles. Schedule two operations per table: daily compaction of hot partitions with OPTIMIZE t REWRITE DATA USING BIN_PACK WHERE dt >= ... (rewrites small and delete-laden files into target-size Parquet), and VACUUM t (expires snapshots past vacuum_max_snapshot_age_seconds and removes unreferenced files — which also ends time travel past that age). If you would rather not own that, the Glue catalog offers automatic compaction for Iceberg tables, and S3 Tables (managed Iceberg buckets) automate compaction, snapshot expiry and GC entirely — trading control for zero ops.

For the full operational treatment — writer tuning, compaction strategies, migration from Hive tables — see Apache Iceberg on S3 with Glue. The zone model with Iceberg curated tables is exactly the lakehouse pattern covered in medallion architecture; the enterprise AWS lakehouse article scales it to multi-account.

Orchestration and consumption: Step Functions, MWAA and QuickSight

Choosing the orchestrator

Pipelines are DAGs: ingest → validate → transform → compact → refresh marts → refresh dashboards. Four reasonable engines run DAGs on AWS; teams go wrong by picking the heaviest one first.

Orchestrator Model Cost (indicative) Strengths Wrong when
EventBridge Scheduler Cron → one target ~free Fire a nightly Glue job Any dependency logic
Glue workflows Trigger-chained Glue jobs/crawlers Free All-Glue pipelines, visual Steps outside Glue
Step Functions State machine, .sync service integrations $0.025 per 1,000 state transitions (Standard) Serverless DAGs across Glue/Athena/Lambda/ECS; retries, catch, Map fan-out; sits naturally with event-driven Lambda patterns Hundreds of interdependent DAGs with data-aware scheduling
MWAA (managed Airflow) Python DAGs, sensors, backfills mw1.small env ≈ $0.49/hr ≈ $350+/mo before workers Big-team standard, rich operators, backfill semantics One team, five pipelines — the fixed cost dwarfs the work

Rule of thumb: start with Step Functions; adopt MWAA only with an Airflow-fluent team and dozens of interdependent DAGs — an idle MWAA environment costs more per month than many small lakes’ entire Athena bill. A Standard state machine chaining a Glue job and an Athena statement with .sync (Step Functions polls completion for you):

{
  "StartAt": "CurateClickstream",
  "States": {
    "CurateClickstream": {
      "Type": "Task",
      "Resource": "arn:aws:states:::glue:startJobRun.sync",
      "Parameters": {"JobName": "curate-clickstream",
                     "Arguments": {"--dt.$": "$.dt"}},
      "Retry": [{"ErrorEquals": ["States.ALL"], "MaxAttempts": 2, "BackoffRate": 2}],
      "Next": "RefreshMart"
    },
    "RefreshMart": {
      "Type": "Task",
      "Resource": "arn:aws:states:::athena:startQueryExecution.sync",
      "Parameters": {
        "QueryString": "INSERT INTO consumption.daily_kpis SELECT ...",
        "WorkGroup": "pipelines"
      },
      "End": true
    }
  }
}

QuickSight: the last mile

QuickSight connects to Athena and Redshift natively and adds SPICE — an in-memory columnar engine that caches datasets so dashboards stop re-querying the lake. That is architecture, not convenience: 500 viewers on SPICE cost zero Athena scans; the same viewers in direct-query mode would issue hundreds of thousands of Athena queries a month.

QuickSight decision Options Guidance
Edition Standard / Enterprise Enterprise for RLS, VPC connections, embedding, hourly SPICE refresh
Pricing (Enterprise, indicative) Author $24/mo ($18 annual); Reader $0.30/session capped $5/mo Readers are cheap at scale — don’t share author seats
SPICE vs direct query Per dataset SPICE for dashboards (refresh on schedule or post-pipeline via API); direct query for operational views on Redshift
SPICE capacity 10 GB/author included; extra ~$0.38/GB-mo; dataset ceilings up to 1 B rows / 1 TB (Enterprise) Model aggregates into SPICE, not raw events
Security Row-level security, column-level security, LF integration (Athena), VPC connection to Redshift RLS rules table keyed by user/group
Refresh Schedule, or create-ingestion API from the pipeline Trigger SPICE refresh as the last Step Functions state — dashboards update minutes after data lands

Architecture at a glance

Read the diagram left to right — it is the article in one picture. Sources (OLTP via DMS CDC, application/clickstream events) enter the ingestion tier, where Kinesis Data Streams is the durable, replayable transport and Firehose is the file-size governor — buffering to 128 MB, converting JSON to Parquet against the Glue schema, writing Hive-partitioned objects into the raw zone. The lake tier is zoned S3 with the Glue Data Catalog as the schema truth every engine resolves, and Lake Formation vending scoped credentials so access is granted by LF-tag at table/column/row level rather than by bucket policy. The query tier reads one copy two ways: Athena for scan-metered ad-hoc SQL with partition projection, Redshift RA3/Serverless for the hot high-concurrency BI slice — Spectrum joining back to the lake so history is never copied. Consumption is QuickSight (SPICE datasets refreshed by the pipeline, so viewers never touch the $5/TB meter) and EMR/Spark on the same Iceberg tables.

The numbered badges mark the six decisions this article keeps returning to: buffer sizing at Firehose (1), partition-or-pay at S3 (2), catalog hygiene at Glue (3), single-point governance at Lake Formation (4), the bytes-scanned meter at Athena (5), and the lake-vs-warehouse boundary at Redshift (6).

AWS data lake and analytics architecture: OLTP databases via DMS and application event producers flow into an ingestion tier of Kinesis Data Streams and Amazon Data Firehose with 128 MB buffering and Parquet conversion; Firehose writes Hive-partitioned Parquet objects into a zoned S3 lake (raw to curated) whose schemas live in the Glue Data Catalog, governed by Lake Formation LF-tags and data filters; Athena engine v3 at 5 dollars per TB scanned and Redshift RA3 with Spectrum and materialized views both query the same catalog; QuickSight SPICE dashboards and EMR/Spark ML consume the results. Numbered badges mark the six architectural decisions: Firehose buffer sizing, partitioning layout, catalog hygiene, Lake Formation governance, Athena scan economics, and the warehouse boundary.

Real-world scenario

Meridian Retail, a fictional-but-familiar omnichannel retailer (1,800 stores, one e-commerce platform, ap-south-1), built its lake in a hurry before Diwali: clickstream and POS events (~4,000 events/s, ~415 GB/day of JSON) through Firehose on default buffers, dynamic partitioning by store_id “so store queries would be fast”, a crawler on a 15-minute cron, forty analysts on Athena with no workgroups. It worked in the demo. By week six the 09:00 IST merchandising dashboards took 40+ seconds per visual, the Athena line hit $6,800/month, and the raw bucket was gaining 1.9 million objects a day averaging 218 KB — 1,800 store partitions × 24 hours × a flush every ~60 seconds. The crawler, meanwhile, re-typed coupon_code from string to bigint one night (a day of numeric-only coupons), and every query touching older partitions failed with HIVE_PARTITION_SCHEMA_MISMATCH until an engineer hand-edited the table at 01:30.

The fix took two weeks and no new services. Ingestion: Firehose buffers to 128 MB/900 s, dynamic partitioning removed — partitioning switched to dt=/hr= from event time, store_id demoted to an ordinary column sorted within files so row-group stats still prune store queries. A nightly Glue job (10 × G.1X, ~22 minutes) compacted the swamp into 256–512 MB ZSTD Parquet, hourly incremental compaction kept it that way; object creation fell to ~4,200/day. Catalog: the crawler moved to LOG mode over raw landing only; curated tables became explicit DDL with partition projection, so the 15-minute crawler cron ($21/day of billing minimums) disappeared along with GetPartitions throttling. Governance: LF-tags (domain, sensitivity=pii on card-holder columns) replaced eleven bucket policies; IAMAllowedPrincipals was revoked database-by-database in hybrid mode. Consumption: analysts got workgroups with a 500 GB per-query cutoff; the six merchandising dashboards moved to SPICE refreshed by the pipeline’s final Step Functions state; the two genuinely hot marts (store-day sales, inventory positions) moved into an 8-RPU Redshift Serverless workgroup, Spectrum unioning history from the lake.

Results, month over month: dashboard p50 from 42 s to 1.8 s (SPICE) and ad-hoc Athena p50 from 31 s to 3.4 s; Athena spend from $6,800 to $1,450 (Parquet + pruning + SPICE absorbing repeat queries); total platform cost from ~$9,900 to ~$4,100/month (~₹8.5 lakh → ~₹3.5 lakh) — while ingest volume grew 12%. The postmortem’s one-line lesson went on the team wiki: defaults are for demos; file size, partitions and the catalog are architecture.

Advantages and disadvantages

Advantages Disadvantages
One copy of data, many engines — storage/compute separation means no per-tool copies Many moving parts (5–8 services) vs one warehouse product; integration is your job
S3 economics: ~$23/TB-month hot, ~$1/TB-month deep archive, 11-nines durability Schema-on-read demands discipline — without zones/contracts it becomes a swamp
Scan-metered querying: pay only when you ask questions; zero idle cost Scan metering punishes bad layout brutally ($5/TB × everything, every time)
Scales ingest and storage independently to PB without re-platforming Small-files and partition sprawl are self-inflicted DDoS — need active compaction
Open formats (Parquet/Iceberg): no lock-in, every engine present and future reads them Eventual-consistency of teams: catalog drift, orphan datasets need governance muscle
Governance once (LF-tags) enforced across Athena/Spectrum/Glue/EMR LF rollout has a sharp edge (IAMAllowedPrincipals) and a learning curve
Warehouse optional and right-sized: Redshift only for the hot slice Sub-second high-concurrency BI still needs that warehouse or SPICE — Athena alone won’t do it

The honest summary: the lake pattern wins on cost, scale and flexibility and charges for it in architectural discipline; a single-product warehouse (Snowflake+dbt, BigQuery, all-in Redshift) buys simplicity and charges in pricing and lock-in. Below ~1 TB and one team, the warehouse alone is genuinely simpler; past tens of TB and three teams, the lake pattern is usually the only affordable shape.

Hands-on lab

Build the miniature version end to end: zoned bucket, raw JSON, Athena with projection, CTAS to Parquet, an Iceberg table with MERGE, and teardown. Cost: well under $1 (Athena minimums + pennies of S3). Prereqs: AWS CLI v2 with a profile that can create S3/Glue/Athena resources.

# 1. Buckets (use your own suffix; names are global)
export ACC=$(aws sts get-caller-identity --query Account --output text)
aws s3 mb s3://lakelab-raw-$ACC && aws s3 mb s3://lakelab-curated-$ACC && aws s3 mb s3://lakelab-scratch-$ACC

# 2. Generate three days of fake orders as JSON Lines, partitioned by dt
for d in 2026-07-04 2026-07-05 2026-07-06; do
  for i in $(seq 1 2000); do
    echo "{\"order_id\":\"O-$d-$i\",\"customer_id\":\"C-$((RANDOM%500))\",\"store_id\":\"S-$((RANDOM%50))\",\"amount\":$((RANDOM%9000+100)).50,\"ts\":\"${d}T$(printf '%02d' $((RANDOM%24))):15:00Z\"}"
  done > orders-$d.json
  aws s3 cp orders-$d.json s3://lakelab-raw-$ACC/sales/orders/dt=$d/
done

# 3. Glue database + Athena workgroup with a scan cap
aws glue create-database --database-input '{"Name":"lakelab"}'
aws athena create-work-group --name lakelab \
  --configuration "ResultConfiguration={OutputLocation=s3://lakelab-scratch-$ACC/results/},EnforceWorkGroupConfiguration=true,BytesScannedCutoffPerQuery=10737418240"
-- 4. Raw table with partition projection (run in the lakelab workgroup)
CREATE EXTERNAL TABLE lakelab.orders_raw (
  order_id string, customer_id string, store_id string,
  amount double, ts string)
PARTITIONED BY (dt string)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
LOCATION 's3://lakelab-raw-REPLACE_ACC/sales/orders/'
TBLPROPERTIES (
  'projection.enabled'='true', 'projection.dt.type'='date',
  'projection.dt.range'='2026-07-01,NOW', 'projection.dt.format'='yyyy-MM-dd',
  'storage.location.template'='s3://lakelab-raw-REPLACE_ACC/sales/orders/dt=${dt}/');

-- 5. Query raw; note "Data scanned" in the result footer
SELECT dt, COUNT(*) orders, SUM(amount) revenue
FROM lakelab.orders_raw WHERE dt = '2026-07-05' GROUP BY dt;

-- 6. CTAS to partitioned ZSTD Parquet (the format conversion in one statement)
CREATE TABLE lakelab.orders_parquet
WITH (format='PARQUET', write_compression='ZSTD',
      external_location='s3://lakelab-curated-REPLACE_ACC/sales/orders/',
      partitioned_by=ARRAY['dt']) AS
SELECT order_id, customer_id, store_id, amount,
       CAST(from_iso8601_timestamp(ts) AS timestamp) AS event_ts, dt
FROM lakelab.orders_raw;

-- 7. Re-run step 5 against orders_parquet: same answer, a fraction of the bytes.
--    SELECT two columns and compare "Data scanned" again — that delta is your bill.

-- 8. Iceberg: current-state table + an upsert + time travel
CREATE TABLE lakelab.orders_ice (
  order_id string, customer_id string, store_id string,
  amount double, event_ts timestamp, dt string)
PARTITIONED BY (dt)
LOCATION 's3://lakelab-curated-REPLACE_ACC/sales/orders_ice/'
TBLPROPERTIES ('table_type'='ICEBERG');

INSERT INTO lakelab.orders_ice SELECT * FROM lakelab.orders_parquet;

MERGE INTO lakelab.orders_ice t
USING (SELECT 'O-2026-07-05-7' order_id, 999.99 amount) s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET amount = s.amount;

SELECT order_id, amount FROM lakelab.orders_ice WHERE order_id='O-2026-07-05-7';
-- Then look at history:
SELECT * FROM "lakelab"."orders_ice$snapshots" ORDER BY committed_at DESC LIMIT 5;
# 9. Teardown — leave nothing metered behind
aws glue delete-database --name lakelab
aws athena delete-work-group --work-group lakelab --recursive-delete-option
for b in raw curated scratch; do
  aws s3 rb s3://lakelab-$b-$ACC --force
done

Expected observations: step 5 scans ~1–2 MB of JSON (billed at the 10 MB minimum — the minimum exists to make you batch small queries); step 7 scans ~10–20× less for the same answer, and selecting two columns cuts it again; the Iceberg $snapshots metadata table shows one snapshot per write — that is your time-travel history and also the thing VACUUM exists to prune.

Common mistakes & troubleshooting

The playbook. Symptoms are what the on-call sees; confirm before fixing — half of these share symptoms.

# Symptom Root cause Confirm (exact place) Fix
1 Every Athena query slow; “Data scanned” ≈ table size No partition pruning: missing predicate, or predicate wraps the partition column in a function Query stats tab: bytes scanned vs table size; EXPLAIN shows full scan Filter on raw partition values (dt >= '2026-07-01'); add projection; educate BI tool date filters
2 Queries slow despite pruning; planning takes longer than reading Small-files swamp aws s3 ls --recursive --summarize on one partition: object count vs bytes Raise Firehose buffers; nightly compaction job; Iceberg OPTIMIZE
3 HIVE_PARTITION_SCHEMA_MISMATCH Crawler re-typed a column; old partitions carry old schema Error names the partition; aws glue get-table vs get-partition schemas Set crawler UpdateBehavior=LOG; align types via ALTER TABLE; recreate table + projection (partitions inherit table schema)
4 HIVE_BAD_DATA: Error parsing field File content doesn’t match declared type (string in a bigint column, malformed JSON line) Athena error gives the S3 object path — inspect it Fix producer; route bad records via Firehose error prefix; declare column as string and CAST in curated ETL
5 Zero rows from a table that has data Partitions not registered (no projection, no MSCK) or projection template/range wrong SHOW PARTITIONS; test one ALTER TABLE ADD PARTITION Enable projection with correct storage.location.template and range
6 Query exhausted resources at this scale factor Huge sort/join/window on the serverless pool Athena query stats: spilled/peak memory Reduce columns, pre-aggregate, bucket the join keys, split the query, or run it in Redshift/Glue
7 Firehose delivering but S3 files are tiny Buffer hints at defaults, or dynamic partitioning over a high-cardinality key Delivery stream config; object sizes in the landing prefix 128 MB/900 s buffers; partition by dt/hr only
8 Records missing from the lake; producers report success Format-conversion failures quarantined errors/ (ErrorOutputPrefix) has objects; DeliveryToS3.DataFreshness fine but SucceedConversion dropping Fix schema mismatch (Glue table vs payload); replay error objects through a repair job
9 WriteProvisionedThroughputExceeded on KDS while average utilisation ~30% Hot shard from skewed partition key Enhanced shard-level metrics: one shard at 1 MB/s, rest idle Re-key to high-cardinality value; or on-demand mode; add producer retries with backoff
10 Glue job dies: Container killed by YARN for exceeding memory limits Skewed shuffle or huge partitions per task CloudWatch job metrics: max needed executors, shuffle spill G.2X/G.4X workers, enable auto scaling, repartition() before wide ops, salt skewed keys
11 Crawler created hundreds of tables for one dataset Inconsistent schemas/paths under one prefix; grouping heuristic gave up Crawler run log lists created tables Set TableLevelConfiguration to the dataset depth; exclusions for stray files; one schema per prefix
12 Spectrum query: Access Denied or empty result while Athena works Cluster IAM role lacks Glue/S3/LF grants; or partitions absent (Spectrum ignores projection) SVL_S3LOG / error detail; SHOW PARTITIONS in the catalog Grant the Redshift role in LF; register real partitions or convert the table to Iceberg
13 DMS CDC lag grows daily Source log retention/misconfig (binlog not ROW, WAL slot dropped) or undersized replication instance DMS task monitoring: CDCLatencySource vs CDCLatencyTarget Fix source log config; scale instance; split hot tables to their own task
14 Iceberg table gets slower every week; S3 bill creeping Snapshot and small-file accumulation from frequent MERGEs $snapshots / $files metadata tables: file count, snapshot count Schedule OPTIMIZE ... BIN_PACK + VACUUM; enable Glue auto-compaction
15 After enabling Lake Formation, a service role that worked yesterday gets Insufficient Lake Formation permission(s) IAMAllowedPrincipals revoked without granting the role LF console → Permissions → filter by table; CloudTrail GetDataAccess denials Grant the role SELECT/DESCRIBE via LF-tags; use hybrid mode during migration

The Athena error strings worth memorising, since they name the layer at fault:

Error string Layer at fault Meaning
HIVE_PARTITION_SCHEMA_MISMATCH Catalog Partition schema ≠ table schema
HIVE_BAD_DATA Files Content doesn’t parse as declared types
HIVE_CANNOT_OPEN_SPLIT S3/permissions Object unreadable: deleted mid-query, KMS/S3 deny, or archived to Glacier FR/DA
HIVE_CURSOR_ERROR Files/S3 Read failed mid-stream — often corrupt/truncated object
Query exhausted resources at this scale factor Engine Memory-heavy plan for the pooled capacity
GENERIC_INTERNAL_ERROR Varies Often schema edge cases (bad Parquet stats, type overflow) — check recent schema changes
Insufficient Lake Formation permission(s) Governance LF enforced, grant missing
SlowDown / 503 in the error detail S3 Request-rate spike on one prefix — almost always the small-files pattern

Best practices

  1. Decide file size at ingestion — 128–512 MB Parquet targets, Firehose buffers at maximum, compaction scheduled from day one. Retrofitting is a hundred times harder.
  2. Zone with different buckets and different writers: only ingestion writes raw, only ETL writes curated, consumers write nowhere. Enforce with IAM/LF, not convention.
  3. Partition by event date (and hour only above ~50 GB/day); never by high-cardinality keys — sort within files instead so row-group stats do that work.
  4. Prefer explicit DDL + partition projection over crawlers for anything consumers query; crawl only landing areas, in LOG mode, with table-level config set.
  5. One Glue catalog, one schema truth — no side-car Hive metastores, no per-tool table definitions; share cross-account via Lake Formation/RAM.
  6. Cap every human with Athena workgroups (BytesScannedCutoffPerQuery, enforced config) and alarm on per-workgroup DataScannedInBytes at 80% of budget.
  7. Adopt Iceberg for mutable tables (CDC state, GDPR-deletable data) and schedule its maintenance (OPTIMIZE/VACUUM) the same day you create the table.
  8. Keep the warehouse a cache, not a fork: Redshift holds hot marts; history stays in the lake behind Spectrum; nothing exists only in Redshift.
  9. Quarantine, never drop: Firehose ErrorOutputPrefix, DLQs on stream consumers, Glue Data Quality gates between zones — with alarms on all three.
  10. Make pipelines idempotent and re-runnable by partition (--dt parameters, deterministic outputs, overwrite-by-partition or MERGE) — backfills and replays are then boring.
  11. Tag spend to the byte: cost allocation tags on buckets/jobs/workgroups per domain; publish a monthly $-per-domain report — behaviour changes fast when scans have names.
  12. Lifecycle deliberately: Intelligent-Tiering for unpredictable curated data, explicit transitions for raw, and never archive partitions that analysts still query into Glacier FR/DA without a restore runbook.

Security notes

Least privilege has three layers here, and each catches what the previous misses:

Layer Control Rule
Storage Bucket policies + SSE-KMS (bucket keys on), Block Public Access at account level, TLS-only (aws:SecureTransport) Humans never hold raw S3 read on lake buckets; only service roles do
Table Lake Formation grants, LF-tags, column exclusions, row/cell data filters Analysts exist only in LF; PII columns behind sensitivity=pii grants
Network Gateway VPC endpoint for S3 (free), interface endpoints for Glue/Athena/KDS/Firehose; Redshift enhanced VPC routing; QuickSight VPC connection Lake traffic never crosses the public internet; endpoint policies pin buckets to your org

Beyond the table: encrypt the streams (KDS/Firehose with the lake CMK) so data is protected before it ever rests; turn on CloudTrail S3 data events for curated/PII buckets and alert on direct-S3 reads that bypass the query engines — that is someone evading LF; run Macie periodically against raw, because PII always leaks into raw eventually; and treat Athena result buckets as sensitive — results contain the data, and teams routinely leave 90-day-old PII extracts readable account-wide. Audit posture pairs with CloudTrail/Config compliance patterns.

Cost & sizing

What drives the bill, per layer — and the lever that moves it:

Layer Billed on Biggest lever Waste pattern
S3 GB-month per class + requests Lifecycle + compaction (fewer, bigger objects) Millions of tiny Standard-class objects, forgotten versions
Kinesis Shard-hours or GB (on-demand) Provisioned when steady; key distribution On-demand for flat 24×7 load
Firehose GB ingested (+ conversion, + dynamic partitioning) Only pay conversion once — land Parquet Dynamic partitioning on high-cardinality keys
DMS Instance-hours Right-size to change rate; Serverless for spiky r5.4xlarge replicating a quiet database
Glue DPU-hours Auto scaling + Flex + bookmarks (incremental) Full-table reprocessing every run; 48 h default timeouts
Athena TB scanned Parquet + partitions + named columns + SPICE in front Dashboards direct-querying raw JSON
Redshift Node-hours / RPU-seconds + RMS GB Serverless for part-time; reserved for steady; MVs Always-on cluster at 6% utilisation
QuickSight Seats + sessions + SPICE GB SPICE absorbs repeat queries Author seats for view-only users
Catalog/LF Objects + requests (LF free) Partition indexes; daily not hourly partitions Million-partition hourly tables kept forever

The Meridian Retail steady state (415 GB/day JSON ingest, ~83 GB/day as Parquet, 12-month hot history, 40 analysts, 6 dashboards, 80 viewers) — us-east-1 list prices, indicative; ap-south-1 runs a few percent higher; ₹ at ~86/USD:

Component Sizing Monthly (USD) Monthly (INR)
Kinesis Data Streams (provisioned) 20 shards + PUT units ~$365 ~₹31,000
Firehose (ingest + Parquet conversion) 12.5 TB/mo ~$585 ~₹50,000
S3 (all zones, post-lifecycle, requests) ~32 TB blended classes ~$350 ~₹30,000
Glue (hourly incremental + nightly compaction + DQ) ~600 DPU-hr ~$285 ~₹24,500
Athena (post-optimisation) ~40 TB scanned ~$200 ~₹17,000
Redshift Serverless (hot marts) 8 RPU × ~6 h/day ~$520 ~₹45,000
QuickSight Enterprise 6 authors + 80 readers (capped) ~$545 ~₹47,000
DMS + Step Functions + CloudWatch + endpoints small instance + misc ~$150 ~₹13,000
Total ~$3,000 ~₹2.6 lakh

Right-sizing rules that fall out of the model: the ingest pipeline (KDS+Firehose ≈ $950) is the floor — it scales with data, not usage, so fight for it first; Athena at $200 versus its pre-optimisation $6,800 is the proof that format/partition work pays permanent dividends; Redshift Serverless earns its $520 only while dashboards genuinely need sub-second — revisit quarterly; and the QuickSight reader cap makes viewer growth nearly free. For learning, the free corners are Glue’s first million catalog objects, Athena’s pennies-per-lab minimums, QuickSight’s 30-day trial and S3’s 5 GB — the lab above exercises all of them.

Interview & exam questions

Mapped to SAA-C03 (analytics design), DEA-C01 (Data Engineer Associate — this article is essentially its core domain), and SAP-C02 (cost/migration trade-offs).

  1. Why does Parquet cut Athena costs so dramatically versus JSON? Columnar layout lets the engine read only referenced columns; per-column compression shrinks bytes 4–8×; row-group min/max statistics skip data pages that can’t match predicates. Together a typical BI query touches 2–10% of the JSON-equivalent bytes, and Athena bills bytes scanned.
  2. What is partition projection and when do you need it? Table properties from which Athena computes valid partitions (date ranges, enums, integers) at plan time instead of fetching them from the catalog. Use it for high-partition-count time-series tables: zero GetPartitions calls, no MSCK/crawler lag, new data instantly queryable. Spectrum and Spark ignore it — they need real partitions or Iceberg.
  3. Kinesis Data Streams vs Firehose — when each? KDS is durable, ordered, replayable transport for multiple/custom consumers (fan-out, replay, per-key ordering); Firehose is fully-managed delivery — buffer, transform, convert, land in S3/Redshift/OpenSearch — with no replay or custom consumers. Classic lake pattern: KDS in front when real-time consumers exist, Firehose behind it as the S3 writer.
  4. Explain the small-files problem and three fixes. Each object costs a request, metadata parse and a scheduling slot, so millions of KB-scale files make planning dominate query time and inflate request costs. Fixes: maximise Firehose buffers (128 MB/900 s), scheduled compaction (Glue job or Iceberg OPTIMIZE ... BIN_PACK), and avoid high-cardinality dynamic partitioning that fragments buffers.
  5. Athena vs Redshift Spectrum vs Redshift local — pick per workload. Athena: standalone, serverless, ad-hoc/scheduled SQL over the catalog. Spectrum: same S3 data but joined from Redshift with its local tables — for extending a warehouse to lake history. Local Redshift: hot, high-concurrency, sub-second repeated BI. Hot slice local, cold history external, one view over both.
  6. Why RA3 over DC2, in one sentence each? RA3 separates compute from Redshift Managed Storage so you scale nodes for CPU/concurrency while storage grows independently (and cheaper); DC2 welds them so growing disk means buying compute you don’t need.
  7. You enabled Lake Formation but permissions don’t seem enforced. Why? The default IAMAllowedPrincipals grant gives every IAM-authorised principal implicit access, making LF grants decorative until you revoke it (per resource or via settings) — after which only explicit LF grants (ideally LF-tags) apply. Hybrid access mode lets you migrate database-by-database.
  8. What do Iceberg tables add over Hive-style Parquet, and what do they cost you? ACID commits with snapshots (time travel), MERGE/UPDATE/DELETE, schema evolution by column ID, hidden/evolvable partitioning, manifest-level file pruning, safe concurrent writers. Cost: metadata/maintenance overhead — compaction and snapshot expiry (OPTIMIZE/VACUUM) must be scheduled or the table degrades.
  9. How do Glue job bookmarks work? The job persists state about processed input (S3 objects/timestamps; JDBC key bounds) per bookmark name; the next run reads only new data. Disable or rewind for backfills; they make incremental pipelines idempotent-ish but your writes must still be partition-overwrite or MERGE to be safely re-runnable.
  10. When is Kinesis on-demand the wrong choice? Steady, predictable, well-utilised throughput — provisioned shards cost several times less per GB at high duty cycle. On-demand wins for spiky, unpredictable or low-volume streams, and as the default until you know the shape.
  11. How do you hard-cap Athena spend for a team? A dedicated workgroup: BytesScannedCutoffPerQuery kills runaway queries, EnforceWorkGroupConfiguration stops overrides, per-workgroup result location + lifecycle, and CloudWatch DataScannedInBytes alarms against a monthly budget. Plus SPICE/result-reuse so dashboards stop hitting the meter.
  12. A CTAS backfill of 3 years of daily partitions fails immediately — why? CTAS/INSERT INTO write at most 100 partitions per statement. Chunk the backfill: CTAS for the first ≤100 days, then looped INSERT INTO ... WHERE dt BETWEEN ... chunks (or do the rewrite in a Glue job where no such limit applies).

Quick check

  1. Your raw zone receives 300 GB/day of JSON and analysts query it directly “temporarily”. Name the two structural problems and the first fix.
  2. Which Firehose settings decide output file size, and what are the production values for a lake?
  3. A query filters WHERE date(dt) >= date '2026-07-01' on a dt-partitioned table and scans the whole table. Why?
  4. When does Redshift Serverless beat a provisioned ra3.xlplus node, numerically?
  5. What two maintenance operations keep an Iceberg table healthy, and what happens if you skip them?

Answers

  1. Row-format scans (5–10× the bytes of Parquet at $5/TB) and a schema contract you now can’t change because consumers depend on raw. Fix: CTAS/Glue into a curated Parquet table and repoint consumers; keep raw for replay only.
  2. Buffer size (1–128 MB) and buffer interval (60–900 s), plus format conversion determining Parquet output; production: 128 MB / 600–900 s with conversion on — and no high-cardinality dynamic partitioning.
  3. Wrapping the partition column in a function prevents partition pruning — Athena can’t map date(dt) back to prefix values, so it scans everything. Rewrite the predicate against raw values: dt >= '2026-07-01'.
  4. Serverless ≈ RPUs × $0.36 × active hours; xlplus ≈ $793/month always-on. 8 RPU active 6 h/day ≈ $520 wins; the same 8 RPU busy 24×7 ≈ $2,073 loses badly — the crossover is roughly 9–10 active hours/day at that size.
  5. Compaction (OPTIMIZE ... REWRITE DATA USING BIN_PACK) and snapshot expiry/orphan cleanup (VACUUM). Skipping them: small delta files accumulate until queries crawl, and storage grows unbounded with stale snapshots (time travel is the feature and the bill).

Glossary

Next steps

AWSS3GlueAthenaRedshiftKinesisLake FormationIceberg
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading