Making Athena Fast and Cheap: Partition Projection, Parquet Layout, CTAS, and Workgroup Byte Limits
Athena bills you for bytes scanned. Here is the partition projection, Parquet layout, CTAS compaction, and workgroup guardrails I use to cut that number by 90 percent.
By Dinesh Chandra
Table of contents
- Bytes scanned is the only number
- Partition projection: stop asking Glue where the files are
- Parquet layout: file size first, sort order second
- CTAS is your compaction job
- Workgroups are the guardrail, not a folder
- Reading query stats like a profiler
- Pitfalls
- FAQ
- Does partition projection replace the Glue Data Catalog?
- What file size should I actually target?
- Is Athena cheaper than Redshift Serverless for the same query?
- Should I use Iceberg tables in Athena?
- Why is my query queued for 30 seconds?
- Do I get charged for failed queries?
- What this means for your pipelines
Athena has one price: roughly five dollars per terabyte scanned, with a ten-megabyte minimum per query. That is the entire cost model. There is no cluster to size, no warehouse to suspend, no concurrency slider. Which sounds simple until you realize it also means every performance problem and every cost problem are the same problem, and the fix is always “read fewer bytes.”
I inherited an analytics account once where Athena was the third
largest line item, behind S3 and EC2, and ahead of every managed
service the team had actually chosen on purpose. Nobody had done
anything stupid. They had a well-organized lake, Parquet
everywhere, sensible table names. What they had was 4,000 daily
queries against tables with no projection, files averaging 3 MB,
and a BI tool that helpfully issued SELECT * to build a preview
pane.
Three weeks of work took that bill down by about 85% and cut p95 query latency from 40 seconds to under 6. None of it was clever. This post is that work, in the order I do it.
If you want the surrounding context of where Athena sits among the other AWS query engines, the AWS data engineering stack guide covers the landscape. This post assumes Athena is already the thing you are running and it is either slow or expensive.
Bytes scanned is the only number
Open the Athena console, run a query, and look at the two numbers underneath: run time and data scanned. The second one is your invoice. Cache it, log it, alert on it.
The four ways to reduce it, in order of how much they usually buy you:
- Prune partitions. Do not read the directories at all.
- Prune columns. Parquet lets Athena read only the columns in
your projection, so
SELECT *is a self-inflicted wound. - Prune row groups. Parquet min/max statistics let the reader skip row groups whose ranges cannot match your predicate — but only if the data is sorted on that column.
- Compress. Snappy is the default and correct answer. ZSTD scans fewer bytes at slightly higher CPU cost and is worth testing on cold archival tables.
Notice that three of those four are layout decisions made at write time, by whatever job produced the files. Athena tuning is mostly upstream work. You are not optimizing the query engine, you are optimizing what you handed it.
flowchart TD
q[Athena query] --> part{"Partition pruned?"}
part -->|no| scanall["Scan every file: full bill"]
part -->|yes| cols{"Columns projected?"}
cols -->|"SELECT *"| scanwide[Read all columns]
cols -->|explicit| rg{"Sorted on predicate?"}
rg -->|no| readall[Read all row groups]
rg -->|yes| skip["Skip row groups via min/max stats"]
skip --> cheap[Small scan, fast query]
Four gates between your query and your invoice. Most teams only close the first one.
Partition projection: stop asking Glue where the files are
Standard partitioned tables store every partition as a row in the
Glue Data Catalog. Athena calls GetPartitions before planning,
and on a table with 200,000 partitions that call is paginated,
slow, and occasionally throttled. I have seen 12 seconds of pure
metadata time on a query that scanned 400 MB.
Partition projection replaces the lookup with arithmetic. You tell
Athena the shape of the partition space in table properties, and
it computes the S3 prefixes directly. No catalog round trip, no
MSCK REPAIR, no crawler, no missing-partition incidents at
midnight UTC when a new day starts.
-- Projection on a date + region layout. No Glue partitions exist
-- for this table at all; Athena derives the prefixes.
CREATE EXTERNAL TABLE events.web_events (
event_id string,
session_id string,
user_id string,
event_type string,
event_time timestamp,
properties map<string,string>
)
PARTITIONED BY (dt string, region string)
STORED AS PARQUET
LOCATION 's3://acme-lake-curated/events/web_events/'
TBLPROPERTIES (
'projection.enabled' = 'true',
'projection.dt.type' = 'date',
'projection.dt.range' = '2023-01-01,NOW',
'projection.dt.format' = 'yyyy-MM-dd',
'projection.dt.interval' = '1',
'projection.dt.interval.unit' = 'DAYS',
'projection.region.type' = 'enum',
'projection.region.values' = 'us,eu,apac',
'storage.location.template' =
's3://acme-lake-curated/events/web_events/dt=${dt}/region=${region}/',
'parquet.compression' = 'SNAPPY'
);
Two rules I enforce. First, projection.dt.range must end in
NOW, not a hardcoded future date, or the table quietly stops
seeing new data on the day you forgot about. Second, keep the
projected space small: date times region here is a few thousand
prefixes. If you project a high-cardinality integer, Athena will
enumerate the whole space and you will have invented a slower
metastore.
The failure mode nobody warns you about: with projection on, a query for a partition that has no files returns zero rows instantly and silently. That is usually what you want. It also means a broken upstream job looks identical to a quiet day. Put a row-count check in your pipeline — the data quality patterns here apply directly — because Athena will no longer error out on a missing partition.
Parquet layout: file size first, sort order second
The single most common lake pathology is small files. Athena opens every file, reads its footer, and plans around it. At 3 MB per file, footer reads and S3 request overhead dominate; the scan bytes are almost irrelevant compared to the wall clock you burn on list and open operations.
My targets, which have not changed in years:
- File size: 128 MB to 512 MB. Below 64 MB you are wasting planning time; above 1 GB you lose parallelism.
- Row group size: 128 MB. The default in most writers is fine.
- Partition size: aim for at least 1 GB of data per partition. If daily partitions give you 20 MB each, partition monthly.
- Partition count: keep it under a few hundred thousand even with projection.
Sort order is the second lever and the one people skip. Parquet
stores min/max per column per row group. If your table is sorted
by event_time within each partition, a predicate on
event_time skips row groups. If the rows are in arrival order,
every row group’s min/max spans the whole day and nothing is
skipped. Same bytes on disk, ten times the scan.
This is the same idea as Snowflake micro-partition pruning and BigQuery clustering. Different engines, one physics: the reader can only skip what the writer made skippable.
CTAS is your compaction job
You do not need Spark to fix file layout. Athena’s CREATE TABLE AS SELECT writes Parquet, respects bucketing, and costs one scan
of the source. For anything under a few hundred gigabytes it is
faster to write and cheaper to run than spinning up
Glue or EMR.
-- Rewrite one day of small files into ~256 MB sorted Parquet.
-- Bucketing keeps user_id lookups on a small file subset.
CREATE TABLE events.web_events_compacted
WITH (
external_location = 's3://acme-lake-curated/events/web_events_v2/dt=2026-05-04/',
format = 'PARQUET',
parquet_compression = 'SNAPPY',
bucketed_by = ARRAY['user_id'],
bucket_count = 16 -- 4 GB day / 16 = 256 MB per file
) AS
SELECT
event_id, session_id, user_id, event_type, event_time, properties
FROM events.web_events_raw
WHERE dt = '2026-05-04'
ORDER BY event_time; -- sort so row-group min/max are narrow
Two things to know before you build a pipeline on this. CTAS
cannot write into a location that already has data, so daily
compaction means one target prefix per day and an atomic swap of
the partition location — or INSERT INTO against an existing
table, which appends but does not let you set bucketing. And a
single CTAS has a 100-partition write limit per statement, which
is why the pattern above compacts one day at a time in a loop
rather than rewriting the table in one shot.
The ORDER BY deserves a note. A global sort forces a single
final stage and slows the CTAS down. For most tables I use
ORDER BY on the timestamp anyway, because the write happens once
and the reads happen thousands of times. If the CTAS becomes the
bottleneck, sort within buckets instead and accept slightly wider
min/max ranges.
Here is the loop I actually schedule, which is boring on purpose:
import time
import boto3
athena = boto3.client("athena")
WORKGROUP = "etl-compaction"
def run(sql: str) -> str:
"""Submit a query and block until it terminates. Raises on failure."""
qid = athena.start_query_execution(
QueryString=sql, WorkGroup=WORKGROUP
)["QueryExecutionId"]
while True:
ex = athena.get_query_execution(QueryExecutionId=qid)["QueryExecution"]
state = ex["Status"]["State"]
if state in ("SUCCEEDED", "FAILED", "CANCELLED"):
break
time.sleep(2)
if state != "SUCCEEDED":
raise RuntimeError(ex["Status"].get("StateChangeReason", state))
# Log this. Bytes scanned per compaction run is your ROI metric.
scanned = ex["Statistics"]["DataScannedInBytes"]
print(f"{qid} scanned {scanned / 1e9:.2f} GB")
return qid
def compact(day: str) -> None:
tmp = f"web_events_tmp_{day.replace('-', '')}"
run(f"DROP TABLE IF EXISTS events.{tmp}")
run(CTAS_TEMPLATE.format(table=tmp, day=day))
# Point the real table's partition at the compacted prefix.
run(
f"ALTER TABLE events.web_events PARTITION (dt='{day}') "
f"SET LOCATION 's3://acme-lake-curated/events/web_events_v2/dt={day}/'"
)
If you find yourself writing much more machinery than this, the
honest answer is that you want table format transactions rather
than prefix swapping. Athena supports Iceberg tables with
OPTIMIZE and VACUUM, which is a genuinely better compaction
story — see Delta Lake vs Iceberg
for how to pick a format before you commit to one.
Workgroups are the guardrail, not a folder
Workgroups look like an organizational feature. They are actually the only enforcement point Athena gives you. Three settings matter and I set all three on every workgroup I create.
Per-query data scanned limit. A hard ceiling. A query that would exceed it is cancelled before it finishes billing. I set BI workgroups to 100 GB and ad-hoc analyst workgroups to 500 GB. This one setting has saved more money than every query rewrite I have ever done, because it turns a $400 mistake into a Slack message.
Per-workgroup limits and alerts. Aggregate thresholds that publish CloudWatch alarms. Use them to find the team, not to stop the query.
Enforced result location and engine version. Pin the output bucket so results do not scatter, and pin the engine version so an upgrade is a decision rather than a surprise regression.
{
"Name": "bi-tableau",
"Configuration": {
"ResultConfiguration": {
"OutputLocation": "s3://acme-athena-results/bi/",
"EncryptionConfiguration": { "EncryptionOption": "SSE_S3" }
},
"EnforceWorkGroupConfiguration": true,
"PublishCloudWatchMetricsEnabled": true,
"BytesScannedCutoffPerQuery": 107374182400,
"EngineVersion": { "SelectedEngineVersion": "Athena engine version 3" },
"ResultConfigurationUpdates": { "RemoveOutputLocation": false }
},
"Tags": [{ "Key": "cost-center", "Value": "analytics" }]
}
One more thing about results: Athena writes every result set to S3 and never cleans them up. On an account running thousands of queries a day, the results bucket becomes a real storage line item. Put a 7-day lifecycle rule on it. Nobody has ever missed a query result from three weeks ago.
Reading query stats like a profiler
When a query is slow, do not guess. Athena’s execution details give you queue time, planning time, and execution time separately, and the distinction tells you what to fix.
- High planning time, low scan: partition metadata. Turn on projection.
- High queue time: concurrency limits. You are hitting the account quota for concurrent DML queries. Separate workgroups do not create separate quotas by default; provisioned capacity does.
- High execution, huge scan: layout. Partition, sort, or compact.
- High execution, small scan: usually a join blowing up. Athena is Trino under the hood, so the standard distributed join advice applies: put the big table on the probe side, and watch for the accidental cross join that SQL anti-patterns warns about.
Provisioned capacity deserves a mention because it changes the cost model entirely. You reserve DPUs — 24 minimum — and pay per DPU-hour instead of per byte. It stops being a bad deal only when you are scanning enormous volumes on a predictable schedule, or when queue time on the on-demand pool is breaking an SLA. For most teams the honest answer is that fixing layout is cheaper than buying capacity.
Pitfalls
SELECT * in a BI tool preview. Every preview pane, every
“explore this table” button, scans every column. Give BI tools
views with an explicit column list and a partition predicate baked
in, not raw tables.
Partition projection with a hardcoded end date. The table
works perfectly until the range expires, then returns zero rows
with no error. Always end date ranges at NOW.
Compressed CSV or JSON in the query path. Gzipped JSON is not splittable, so one file is one reader regardless of size, and there is no column pruning at all. Convert to Parquet at ingest — Firehose can do it for you at delivery time.
Treating CREATE TABLE and the S3 prefix as independent.
Dropping an Athena external table does not delete the data;
dropping a CTAS table does not either, and then the next CTAS to
that location fails because the prefix is not empty. Clean the
prefix explicitly.
No byte limit on the ad-hoc workgroup. One analyst, one
missing WHERE dt =, one afternoon, four figures. The limit costs
nothing to set.
Using Athena as a transformation engine on a schedule. Athena is excellent for queries and acceptable for compaction. If you are chaining fifteen CTAS statements to build a warehouse, you want dbt with incremental models or an actual Spark job.
FAQ
Does partition projection replace the Glue Data Catalog?
No, only the partition entries. The table definition, columns, and
types still live in the catalog. Projection means Athena stops
calling GetPartitions and computes prefixes itself. Other
engines reading the same table still need real partitions unless
they support projection too, which is worth checking before you
flip it on for a table Spark also reads.
What file size should I actually target?
128 MB to 512 MB per file for Parquet. Below that the per-file overhead dominates; above 1 GB you start losing parallelism because a single file is read by fewer workers. If your daily partition is under 128 MB total, the answer is not a smaller file target, it is a coarser partition.
Is Athena cheaper than Redshift Serverless for the same query?
For infrequent queries on well-partitioned data, almost always yes, because you pay nothing between queries. For dashboards hitting the same tables hundreds of times an hour, the byte charges add up fast and a warehouse with caching wins. I go through the crossover in detail in Redshift Serverless vs Snowflake.
Should I use Iceberg tables in Athena?
If you need row-level updates, deletes, or time travel, yes.
Iceberg in Athena gives you MERGE, OPTIMIZE for compaction,
and snapshot isolation without the prefix-swapping I described
above. The tradeoff is metadata files that need their own
maintenance, and slightly slower planning on very large tables.
Why is my query queued for 30 seconds?
Account-level concurrent query quotas. On-demand Athena shares a pool, and workgroups do not partition it. Request a quota increase first; it is often granted. If queueing is structural and tied to a latency SLA, provisioned capacity is the only real fix.
Do I get charged for failed queries?
Cancelled and failed queries are not charged for bytes scanned in most cases, but a query cancelled by a workgroup byte limit has still done the scanning up to that point. Treat the limit as a circuit breaker, not a free rollback.
What this means for your pipelines
Athena rewards work done upstream. Every hour you spend on file sizes, sort order, and partition granularity in the job that writes the data pays back across every query anyone runs against it, forever. Every hour you spend tuning the query itself pays back once. So when Athena is slow, my first question is never about the SQL — it is about what wrote the files.
Do these four things in order and you will capture most of the available win. Turn on partition projection for any table with more than a few thousand partitions. Set a per-query bytes-scanned limit on every workgroup today, before you optimize anything. Compact to 128 to 512 MB Parquet with a sort on your most common time predicate. Then, and only then, look at individual slow queries.
The deeper point is that Athena’s pricing model is honest in a way that cluster pricing is not. There is no idle waste to hide behind and no reserved capacity to amortize your mistakes. Every wasted byte shows up on the invoice with your name on it. That is uncomfortable for a month and then it makes you a better engineer, because it is the only cost model I know of where the fast thing and the cheap thing are always the same thing.
Enjoyed this post?
Get the next one in your inbox — one email a week, no spam.
Newsletter signup is not live yet. Use the contact form if you want to be notified.