Amazon S3 Select lets you send a SQL expression with a request for a single S3 object. S3 parses the object on the server, keeps only the records that match, and streams back just those records. When you need 2 MB out of a 4 GB CSV, that means 2 MB on the wire instead of 4 GB, and the client never has to parse the rest.
Start with the status. AWS's documentation now says that Amazon S3 Select is no longer available to new customers, and that existing customers can continue to use it as usual. If your account never used it, you cannot adopt it now, and the useful parts of this article for you are the alternatives at the end. If your account does use it, you are running a feature with no future roadmap. You need to operate it correctly (several of its failure modes are silent) and plan a migration. This page covers both.
What S3 Select actually does
The API operation is SelectObjectContent: a POST /{key}?select&select-type=2 request whose body names the SQL expression, an InputSerialization describing how to parse the object, an OutputSerialization describing how to format results, and optionally a ScanRange and RequestProgress. S3 reads the object (or the byte range), decompresses it if needed, splits it into records, evaluates the WHERE clause on each record, projects the SELECT list, and streams the output back as a sequence of event messages over a chunked HTTP response.
Three properties follow from that design and drive everything else. It works on one object per request; there is no prefix or table concept. It is a row filter, not a query engine: no joins, subqueries, GROUP BY or ORDER BY. And S3 still reads the bytes; what you save is transfer and client-side parsing, not the scan itself (Parquet is the exception, since S3 can skip columns).
Formats, compression and hard limits
| Aspect | What the documentation allows |
|---|---|
| Input formats | CSV, JSON (DOCUMENT or LINES), Apache Parquet; UTF-8 only |
| Output formats | CSV or JSON only, never Parquet; nested data can only be emitted as JSON |
| Compression | GZIP or BZIP2 for CSV and JSON; GZIP or Snappy columnar compression for Parquet; no whole-object compression for Parquet |
| Objects per request | One |
| Object size | Up to 5 TB |
| SQL expression | Up to 256 KB |
| Record size | Up to 1 MB, input or output |
| Parquet row group | Up to 512 MB uncompressed; selecting a repeated field returns only its last value |
| Storage classes | Not Glacier Flexible Retrieval, Glacier Deep Archive, RRS, or the Intelligent-Tiering archive tiers |
| Encryption | SSE-S3 and SSE-KMS are transparent; SSE-C requires HTTPS and the key headers |
| Access | s3:GetObject required; anonymous access not supported |
| Not supported on | Directory buckets and S3 on Outposts |
| Console | Results capped at 40 MB; use the CLI, SDK or API for more |
There is no separate s3:SelectObjectContent permission: anyone who can GetObject can run Select on that object. That matters when you review bucket policies (see AWS IAM): Select is a way of reading, so it is governed exactly like reading.
The SQL subset, and the traps in it
S3 Select supports one statement, SELECT, with a select list, a FROM clause, a WHERE clause and LIMIT. Aggregates COUNT, SUM, AVG, MIN and MAX work over the whole result, which makes SELECT count(*) a cheap way to count matching rows, but there is no grouping.
CSV columns. With FileHeaderInfo set to USE, the header row supplies column names. With IGNORE or NONE, you refer to columns by position as _1, _2 and so on. Every CSV value is text, so numeric comparisons need an explicit cast; without one, '9' > '10' compares as strings. A cast on a dirty value (an empty field, "N/A") can fail the request with a cast error rather than skip the row. NULLIF is supported, so CAST(NULLIF(_4, '') AS DECIMAL) turns empty fields into NULL first; a NULL comparison is never true, so those rows drop out. Test this on a sample containing your real dirty values, because the documentation does not promise any evaluation order inside AND, so a guard like _4 <> '' AND CAST(_4 AS DECIMAL) > 500 is not guaranteed to protect the cast.
JSON paths. S3 treats a JSON object as an array of root values, so a path must start with S3Object[*], for example FROM S3Object[*].Rules[*] r. When a wildcard path matches nothing, S3 Select emits a MISSING value, which output serialization turns into an empty record {}. Filter with WHERE x IS NOT MISSING or downstream code will see phantom rows.
Names. Unquoted header and attribute names are case-insensitive; double-quoted names are case-sensitive. Two headers differing only by case cause AmbiguousFieldName unless quoted, and a quoted name that does not exist causes MissingHeaderName. A column named after a reserved word, such as CAST, must be quoted.
One line. The SQL reference notes that S3 Select does not accept queries containing line breaks, so each expression below is a single line; build them that way in code too.
-- headerless CSV (FileHeaderInfo=NONE), columns: _1 order_id, _2 customer_id, _3 status, _4 amount
SELECT s._1, s._2, s._4 FROM S3Object s WHERE s._3 = 'FAILED' AND CAST(NULLIF(s._4, '') AS DECIMAL) > 500
-- CSV with a header row (FileHeaderInfo=USE): names instead of positions
SELECT s.order_id, s.amount FROM S3Object s WHERE s.status = 'FAILED' LIMIT 1000
-- JSON LINES: nested path, drop MISSING phantoms
SELECT r.id, r.expr FROM S3Object[*].Rules[*] r WHERE r.expr IS NOT MISSING
-- Counting without downloading
SELECT count(*) FROM S3Object s WHERE s.country = 'DE'
Calling it correctly from Python
The response is not a body you can read(). It is an event stream, and boto3 exposes it as an iterator of dicts keyed by event type. Records events carry result bytes. Progress events (if requested) and Cont keep-alives can be ignored. Stats gives bytes scanned, processed and returned, and End says the query finished. Two things are easy to get wrong. A Records chunk is a block of bytes, not a record, so a record can be split across two chunks; buffer and split on your delimiter. And because the HTTP status is 200 before the query has finished, a stream that stops without an End event is a failed request, not a short result. Code that loops over Records and never checks for End will treat truncated data as complete.
import json, boto3
s3 = boto3.client("s3")
def select_rows(bucket, key, sql, scan_range=None, header="NONE"): # header: NONE, IGNORE or USE
kwargs = dict(
Bucket=bucket, Key=key, ExpressionType="SQL", Expression=sql,
InputSerialization={"CSV": {"FileHeaderInfo": header}, "CompressionType": "NONE"},
OutputSerialization={"JSON": {"RecordDelimiter": "\n"}},
)
if scan_range:
kwargs["ScanRange"] = {"Start": scan_range[0], "End": scan_range[1]}
resp = s3.select_object_content(**kwargs)
buf, ended, stats = b"", False, None
for event in resp["Payload"]:
if "Records" in event:
buf += event["Records"]["Payload"]
*lines, buf = buf.split(b"\n") # keep the partial tail for the next chunk
for line in lines:
if line:
yield json.loads(line)
elif "Stats" in event:
stats = event["Stats"]["Details"]
elif "End" in event:
ended = True
if buf.strip():
yield json.loads(buf)
if not ended:
raise IOError(f"S3 Select stream for s3://{bucket}/{key} ended without End event")
log_stats(key, stats) # BytesScanned, BytesProcessed, BytesReturnedLog the Stats event every time. The ratio of BytesReturned to BytesScanned is your selectivity. If it is close to 1, S3 Select is doing nothing for you and a plain GET is simpler.
Scan ranges and parallelism
One request returns one stream, so a multi-gigabyte object can take a long time. ScanRange lets you split it. You give a byte range, and S3 processes every record whose first byte falls in that range, even if the record runs past the end. Ranges therefore do not need to line up with records: tile the object with adjacent, non-overlapping ranges and each record is processed exactly once.
The restriction is format. Scan ranges work for Parquet (row groups that start in the range are processed), uncompressed CSV without quoted record delimiters, and uncompressed JSON in LINES mode. They do not work on GZIP or BZIP2 objects, because a compressed stream cannot be entered at an arbitrary byte. If you want parallel Select, store uncompressed CSV or JSON lines, or Parquet.
from concurrent.futures import ThreadPoolExecutor
def parallel_select(bucket, key, sql, part_bytes=256 * 1024 * 1024, workers=16):
size = s3.head_object(Bucket=bucket, Key=key)["ContentLength"]
ranges = [(start, min(start + part_bytes, size) - 1) for start in range(0, size, part_bytes)]
with ThreadPoolExecutor(workers) as pool:
parts = pool.map(lambda r: list(select_rows(bucket, key, sql, r)), ranges)
return [row for part in parts for row in part] # every range must reach End or the whole call failsHeader rows and scan ranges do not mix well. The header sits only in the range that starts at byte 0, and the documentation does not define how USE or IGNORE behave in later ranges. Do not combine them. For data you query in parallel, write headerless files and use FileHeaderInfo=NONE with positional _N columns, which mean the same thing in every range.
Worked example: pulling failed orders from a daily export
A nightly job writes orders/2026-10-02.csv: 6 GB, uncompressed, 40 million rows, no header, columns in a fixed documented order. Support needs the roughly 30,000 orders that failed with an amount above 500. Without Select, a worker downloads 6 GB, parses 40 million rows and throws away almost all of them. With Select, using the headerless query from the SQL section, FileHeaderInfo=NONE and 24 scan ranges of 256 MB each, every worker receives only matching rows as JSON lines. The Stats events report roughly 6 GB scanned and a few megabytes returned. The worker needs no large memory or disk, and the job's time is set by S3's scan throughput across 24 ranges rather than by one download.
Three decisions made that work. Uncompressed CSV allowed scan ranges; a GZIP file would have forced one request per object. Dropping the header made every range read columns identically. And NULLIF around the amount means an empty field becomes NULL instead of a cast error. A non-numeric value such as "N/A" would still fail, so the exporter's contract is the real fix. Billing follows the same split, since S3 Select charges for data scanned and data returned plus requests. Check the current S3 pricing page for your region rather than relying on numbers in old blog posts.
Failure modes
| Failure | Cause | Fix |
|---|---|---|
| Silent truncation | Client ignored a missing End event | Treat no End as an error and retry the range |
| JSON decode errors mid-stream | Record split across Records chunks | Buffer and split on the delimiter |
| Cast error on one bad row | CAST on empty or non-numeric CSV field | NULLIF for empty fields; clean non-numeric values upstream |
| Wrong numeric results | String comparison without CAST | Cast every numeric CSV column |
| Phantom empty records | MISSING from JSON wildcard paths | IS NOT MISSING filter |
| Record too large | Single record or output row over 1 MB | Restructure the data; Select cannot process it |
| Rows split on embedded newlines | Quoted fields containing newlines | AllowQuotedRecordDelimiter (no scan ranges then) |
| Request rejected | Object in an archive class or tier, directory bucket, anonymous caller | Restore or copy the object; use signed requests |
| SSE-C failure | HTTP endpoint or missing key headers | Use HTTPS and send the customer key headers |
| New account cannot call it | Feature closed to new customers | Use Athena or a client-side reader |
Choosing an alternative and migrating
AWS points customers to other ways of querying data in S3. In practice there are three replacements, and the right one depends on what your Select calls do.
| Your Select usage | Replacement | Why |
|---|---|---|
| Ad hoc or scheduled SQL across many objects | Amazon Athena | Real SQL (GROUP BY, joins) over a whole prefix, partition pruning |
| Low-latency filter inside a service, Parquet data | Ranged GETs with a Parquet reader (pyarrow, DuckDB) | The reader fetches the footer, then only the needed row groups and columns |
| Low-latency filter, CSV or JSON data | Convert to Parquet once, then as above | Columnar layout gives you projection and statistics-based skipping |
| Tiny objects, low selectivity | Plain GetObject | Select adds complexity for no saving |
The client-side Parquet route recovers most of what Select offered. A Parquet file's footer holds per-row-group min/max statistics, so a reader can skip row groups whose range cannot match and fetch only the columns you project, using ordinary byte-range GETs.
import pyarrow.dataset as ds
import pyarrow.compute as pc
dataset = ds.dataset("s3://acme-exports/orders_parquet/dt=2026-10-02/", format="parquet")
table = dataset.to_table(
columns=["order_id", "customer_id", "amount"],
filter=(pc.field("status") == "FAILED") & (pc.field("amount") > 500),
)Migrate in four steps. Inventory callers by searching code for select_object_content and SelectObjectContent, and check CloudTrail data events if you have them enabled. Then classify each caller by the table above, convert the hottest datasets to Parquet with a data lake layout, and run old and new paths side by side and compare row counts before cutting over. Keep encryption settings unchanged; the options in S3 encryption options apply to ranged GETs exactly as they did to Select.
What to do next
- Check whether your account can still call
SelectObjectContent; if not, skip to the alternatives. - Find every caller in your code and confirm each one fails loudly when the End event is missing.
- Add buffering across Records chunks and log Stats (scanned versus returned) for every request.
- Audit SQL for uncast numeric CSV comparisons, casts without
NULLIF, headers combined with scan ranges, and multi-line expressions; addIS NOT MISSINGto JSON wildcard queries. - Measure selectivity; remove Select wherever returned is close to scanned.
- Convert the datasets you query most to Parquet and prototype the pyarrow or Athena replacement.
- Run old and new paths in parallel, compare outputs, then retire the Select code path.