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.

Advertisement

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).

S3 Select: filter inside S3, stream back only the matching recordsClientboto3 / SDK / CLISelectObjectContentSQL + input/output formatPOST ?selectS3 objectCSV / JSON / Parquetscan rangeParse recordsdecompress, splitbytes scannedWHERE + projectionper record, no joinsEvent streamRecords (chunked bytes)Progress / Cont (keep-alive)Stats (scanned, processed,returned)End (only now complete)bytes returnedClosed to newcustomers since 2024Cost and latency follow bytes scanned; network transfer follows bytes returned.
S3 Select reads and parses the object inside S3, applies the WHERE clause and projection per record, and streams Records, Progress, Stats and End events back. Only the End event marks a complete result.

Formats, compression and hard limits

AspectWhat the documentation allows
Input formatsCSV, JSON (DOCUMENT or LINES), Apache Parquet; UTF-8 only
Output formatsCSV or JSON only, never Parquet; nested data can only be emitted as JSON
CompressionGZIP or BZIP2 for CSV and JSON; GZIP or Snappy columnar compression for Parquet; no whole-object compression for Parquet
Objects per requestOne
Object sizeUp to 5 TB
SQL expressionUp to 256 KB
Record sizeUp to 1 MB, input or output
Parquet row groupUp to 512 MB uncompressed; selecting a repeated field returns only its last value
Storage classesNot Glacier Flexible Retrieval, Glacier Deep Archive, RRS, or the Intelligent-Tiering archive tiers
EncryptionSSE-S3 and SSE-KMS are transparent; SSE-C requires HTTPS and the key headers
Accesss3:GetObject required; anonymous access not supported
Not supported onDirectory buckets and S3 on Outposts
ConsoleResults 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.

Advertisement

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, BytesReturned

Log 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 fails

Header 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

FailureCauseFix
Silent truncationClient ignored a missing End eventTreat no End as an error and retry the range
JSON decode errors mid-streamRecord split across Records chunksBuffer and split on the delimiter
Cast error on one bad rowCAST on empty or non-numeric CSV fieldNULLIF for empty fields; clean non-numeric values upstream
Wrong numeric resultsString comparison without CASTCast every numeric CSV column
Phantom empty recordsMISSING from JSON wildcard pathsIS NOT MISSING filter
Record too largeSingle record or output row over 1 MBRestructure the data; Select cannot process it
Rows split on embedded newlinesQuoted fields containing newlinesAllowQuotedRecordDelimiter (no scan ranges then)
Request rejectedObject in an archive class or tier, directory bucket, anonymous callerRestore or copy the object; use signed requests
SSE-C failureHTTP endpoint or missing key headersUse HTTPS and send the customer key headers
New account cannot call itFeature closed to new customersUse 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 usageReplacementWhy
Ad hoc or scheduled SQL across many objectsAmazon AthenaReal SQL (GROUP BY, joins) over a whole prefix, partition pruning
Low-latency filter inside a service, Parquet dataRanged 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 dataConvert to Parquet once, then as aboveColumnar layout gives you projection and statistics-based skipping
Tiny objects, low selectivityPlain GetObjectSelect 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

  1. Check whether your account can still call SelectObjectContent; if not, skip to the alternatives.
  2. Find every caller in your code and confirm each one fails loudly when the End event is missing.
  3. Add buffering across Records chunks and log Stats (scanned versus returned) for every request.
  4. Audit SQL for uncast numeric CSV comparisons, casts without NULLIF, headers combined with scan ranges, and multi-line expressions; add IS NOT MISSING to JSON wildcard queries.
  5. Measure selectivity; remove Select wherever returned is close to scanned.
  6. Convert the datasets you query most to Parquet and prototype the pyarrow or Athena replacement.
  7. Run old and new paths in parallel, compare outputs, then retire the Select code path.
Key takeaway: S3 Select filters one CSV, JSON or Parquet object inside S3 and streams back only matching records, which saves transfer and client parsing but not the scan. It is closed to new customers. If you still use it, read the event stream correctly (buffer across Records chunks, require the End event, log Stats), cast CSV numbers carefully, use scan ranges only on uncompressed or Parquet data, and plan a migration to Athena for SQL across objects or to ranged-GET Parquet readers for low-latency filters.