Photo by Carlos Muza on Unsplash
To run an Athena query with AWS SDK v3, send StartQueryExecutionCommand from @aws-sdk/client-athena with the SQL, a workgroup and, if the workgroup doesn’t set one, an S3 output location. Athena returns a QueryExecutionId immediately; poll GetQueryExecutionCommand until the state is SUCCEEDED, then page through GetQueryResultsCommand, skipping the header row on the first page.
Athena is asynchronous, which trips up code written as if a query were a function call. There’s no single “run and return rows” API: you start the query, wait for it, then fetch results in pages of up to 1,000 rows, all as strings. This guide is for Node.js and TypeScript developers who query S3 data from scripts, Lambda functions or back-end jobs and want that loop done once, properly.
You’ll end up with a small typed helper that can run an Athena query with AWS SDK v3, pass parameters safely, back off while polling, convert cells to numbers and booleans, stop runaway queries and report what each scan cost. If your data lands in S3 from other code, the guide to list all objects in an S3 bucket with SDK v3 is useful background. A common source is a stream: records sent with Kinesis PutRecords in AWS SDK v3 can be delivered to S3 by Amazon Data Firehose and queried from there.
Prerequisites
- Node.js 18 or later, TypeScript and
tsx, with"type": "module"inpackage.json. - The
@aws-sdk/client-athenapackage. - A table in the AWS Glue Data Catalog and an Athena workgroup. The default workgroup is
primary. - A query result location: either set on the workgroup, or an S3 URI you pass per query, or Athena’s managed query results.
- Credentials the SDK can resolve, as covered in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.
How an Athena query runs, step by step
- Start the query
StartQueryExecutionCommandwithQueryString,WorkGroup,QueryExecutionContext(database and catalog) andExecutionParametersfor any?placeholders. Pass aClientRequestTokenso a retried request can’t start the query twice. - Poll the state
GetQueryExecutionCommandreturnsQUEUED,RUNNING,SUCCEEDED,FAILEDorCANCELLED. Start at 250 ms and double up to a few seconds, with jitter. - Handle failureOn
FAILED, readStatus.AthenaError:ErrorMessage,ErrorCategory(1 system, 2 user, 3 other) andRetryable. - Page through results
paginateGetQueryResultswith a page size of up to 1,000 rows. Column names and types come fromResultSetMetadata.ColumnInfo. - Drop the header rowFor a
SELECT, the first row of the first page holds the column names, not data. - Record the scan
Statistics.DataScannedInBytesis what you pay for.
Example: a typed helper to run an Athena query with AWS SDK v3
// athena-query.ts
// Run an Athena SQL query with AWS SDK for JavaScript v3: start it, poll until it finishes,
// then page through GetQueryResults and return typed rows plus the bytes scanned.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import {
AthenaClient,
GetQueryExecutionCommand,
paginateGetQueryResults,
StartQueryExecutionCommand,
StopQueryExecutionCommand,
type ColumnInfo,
type QueryExecution,
} from "@aws-sdk/client-athena";
const athena = new AthenaClient({}); // Region from AWS_REGION or the profile
export interface QueryOptions {
workgroup?: string; // default "primary"
database?: string;
catalog?: string; // default "AwsDataCatalog"
outputLocation?: string; // s3://bucket/prefix/ ; not needed if the workgroup sets one
params?: string[]; // values for the ? placeholders, as SQL literals: "'2027-03-01'", "42"
timeoutMs?: number;
}
export type Cell = string | number | boolean | null;
export interface QueryResult {
queryId: string;
columns: { name: string; type: string }[];
rows: Record<string, Cell>[];
scannedBytes: number;
engineMs: number;
}
export class AthenaQueryError extends Error {
queryId: string;
state: string;
retryable: boolean;
constructor(message: string, queryId: string, state: string, retryable: boolean) {
super(message);
this.name = "AthenaQueryError";
this.queryId = queryId;
this.state = state;
this.retryable = retryable;
}
}
/** Convert Athena's string cells using the column type from ResultSetMetadata. */
function convert(value: string | undefined, col: ColumnInfo): Cell {
if (value === undefined) return null; // SQL NULL has no VarCharValue
switch (col.Type) {
case "tinyint":
case "smallint":
case "integer":
case "float":
case "real":
case "double":
return Number(value);
case "bigint":
case "decimal": // keep exact: parse later with BigInt or a decimal library if you need to
return value;
case "boolean":
return value === "true";
default:
return value; // varchar, date, timestamp, arrays, maps and rows stay strings
}
}
async function waitForQuery(queryId: string, timeoutMs: number): Promise<QueryExecution> {
const started = Date.now();
let delay = 250;
for (;;) {
const { QueryExecution: qe } = await athena.send(new GetQueryExecutionCommand({ QueryExecutionId: queryId }));
const state = qe?.Status?.State;
if (qe && (state === "SUCCEEDED" || state === "FAILED" || state === "CANCELLED")) return qe;
if (Date.now() - started > timeoutMs) {
await athena.send(new StopQueryExecutionCommand({ QueryExecutionId: queryId })); // stop paying for the scan
throw new AthenaQueryError(`timed out after ${timeoutMs} ms`, queryId, "CANCELLED", true);
}
await sleep(delay + Math.random() * delay * 0.2);
delay = Math.min(delay * 2, 5000); // 250 ms, 500 ms, 1 s ... capped at 5 s
}
}
export async function runQuery(sql: string, opts: QueryOptions = {}): Promise<QueryResult> {
const { QueryExecutionId: queryId } = await athena.send(
new StartQueryExecutionCommand({
QueryString: sql,
ClientRequestToken: randomUUID(), // idempotent: an SDK retry can't start the query twice
WorkGroup: opts.workgroup ?? "primary",
QueryExecutionContext: opts.database ? { Database: opts.database, Catalog: opts.catalog ?? "AwsDataCatalog" } : undefined,
ResultConfiguration: opts.outputLocation ? { OutputLocation: opts.outputLocation } : undefined,
ExecutionParameters: opts.params?.length ? opts.params : undefined,
}),
);
if (!queryId) throw new Error("StartQueryExecution returned no QueryExecutionId");
const qe = await waitForQuery(queryId, opts.timeoutMs ?? 5 * 60_000);
const state = qe.Status?.State ?? "UNKNOWN";
if (state !== "SUCCEEDED") {
const e = qe.Status?.AthenaError;
const reason = e?.ErrorMessage ?? qe.Status?.StateChangeReason ?? state;
throw new AthenaQueryError(`${state}: ${reason}`, queryId, state, e?.Retryable ?? false);
}
const columns: ColumnInfo[] = [];
const rows: Record<string, Cell>[] = [];
let first = true;
for await (const page of paginateGetQueryResults({ client: athena, pageSize: 1000 }, { QueryExecutionId: queryId })) {
if (!columns.length) columns.push(...(page.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []));
for (const row of page.ResultSet?.Rows ?? []) {
const cells = row.Data ?? [];
// For SELECT, the first row of the first page repeats the column names.
if (first) {
first = false;
if (cells.every((c, i) => c.VarCharValue === columns[i]?.Name)) continue;
}
const record: Record<string, Cell> = {};
columns.forEach((col, i) => {
record[col.Name ?? `col${i}`] = convert(cells[i]?.VarCharValue, col);
});
rows.push(record);
}
}
return {
queryId,
columns: columns.map((c) => ({ name: c.Name ?? "", type: c.Type ?? "" })),
rows,
scannedBytes: qe.Statistics?.DataScannedInBytes ?? 0,
engineMs: qe.Statistics?.EngineExecutionTimeInMillis ?? 0,
};
}
/** Athena SQL pricing: $5 per TB scanned, rounded up per MB, 10 MB minimum (us-east-1, September 2026). */
export function estimateCostUsd(scannedBytes: number): number {
const mb = Math.max(10, Math.ceil(scannedBytes / 1024 ** 2));
return (mb / 1024 ** 2) * 5;
}
A caller that asks for 5xx responses on one day from a table of load balancer logs:
// run-athena.ts
// Usage: AWS_PROFILE=analytics AWS_REGION=us-east-1 npx tsx run-athena.ts 2027-03-01
import { AthenaQueryError, estimateCostUsd, runQuery } from "./athena-query.js";
const day = process.argv[2] ?? new Date().toISOString().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new Error("pass a date as YYYY-MM-DD");
const sql = `
SELECT elb_status_code, count(*) AS requests, approx_percentile(target_processing_time, 0.95) AS p95_seconds
FROM alb_logs
WHERE day = ? AND elb_status_code >= ?
GROUP BY elb_status_code
ORDER BY requests DESC
LIMIT 20`;
try {
const result = await runQuery(sql, {
workgroup: "analytics",
database: "weblogs",
params: [`'${day}'`, "500"], // string parameters need single quotes; numbers don't
timeoutMs: 120_000,
});
console.table(result.rows);
const mb = (result.scannedBytes / 1024 ** 2).toFixed(1);
console.log(`${result.rows.length} rows, ${mb} MB scanned (~$${estimateCostUsd(result.scannedBytes).toFixed(4)}), ${result.engineMs} ms, id ${result.queryId}`);
} catch (err) {
if (err instanceof AthenaQueryError) {
console.error(`Query ${err.queryId} ${err.state}${err.retryable ? " (retryable)" : ""}: ${err.message}`);
process.exit(1);
}
throw err;
}
npm install @aws-sdk/client-athena
npm install --save-dev tsx typescript @types/node
npm pkg set type=module
AWS_PROFILE=analytics AWS_REGION=us-east-1 npx tsx run-athena.ts 2027-03-01
# ┌─────────┬─────────────────┬──────────┬─────────────┐
# │ (index) │ elb_status_code │ requests │ p95_seconds │
# ├─────────┼─────────────────┼──────────┼─────────────┤
# │ 0 │ 502 │ 1841 │ 0.012 │
# │ 1 │ 504 │ 97 │ 29.87 │
# │ 2 │ 500 │ 12 │ 0.431 │
# └─────────┴─────────────────┴──────────┴─────────────┘
# 3 rows, 8412.6 MB scanned (~$0.0401), 3120 ms, id 5f1c2a0e-8d3b-4c7e-9a61-2b4f0e7d9c13
The table name, columns and numbers are illustrative. approx_percentile is one of the Trino aggregate functions; Athena engine version 3 bases its SQL functions on Trino, so the Trino documentation is the reference for syntax.
Parameters, not string concatenation
ExecutionParameters fill the ? placeholders in order and work for SELECT, INSERT INTO, CTAS and UNLOAD. Each value is a SQL literal, so strings need single quotes ("'2027-03-01'") and numbers don’t ("500"). A placeholder can’t sit inside quotes, and named parameters aren’t supported. Use CAST('2027-03-01' AS DATE) as the value when the column is a date.
Parameters keep a value from being read as SQL, but you still build the literal, so validate input before quoting it, as run-athena.ts does with the date. Table and column names can’t be parameters at all; pick them from a fixed list.
What does each query cost?
As of September 2026, Athena SQL queries in us-east-1 cost $5 per TB scanned, rounded up to the nearest megabyte, with a 10 MB minimum per query (AWS Price List and the Athena pricing page, checked 28 September 2026). Query results written to S3 are billed as normal S3 storage. estimateCostUsd() applies that rule to DataScannedInBytes:
| Data scanned | Billed | Approximate cost |
|---|---|---|
| 120 KB (small lookup) | 10 MB minimum | 10 ÷ 1,048,576 × $5 = $0.00005 |
| 8,412.6 MB (one day of logs) | 8,413 MB | 8,413 ÷ 1,048,576 × $5 = $0.040 |
| 2 TB (full scan, run hourly) | 2 TB × 720 runs a month | 2 × $5 × 720 = $7,200 a month |
The last row is how Athena bills surprise people: a dashboard refresh on an unpartitioned table. Partition by date and filter on the partition column, store data in a columnar format such as Apache Parquet so a query reads only the columns it names, and set a per-query data usage limit on the workgroup. Athena’s results bucket also fills up with CSV files; add an expiration rule with the script to find S3 buckets without lifecycle rules. The S3 requests behind a scan are billed too; the guide to calculate S3 GET and PUT request costs covers that side.
Permissions needed
A query touches three services: Athena for the query, Glue for the table definition, and S3 for both the data and the results. Replace the Region, account ID, names and buckets:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RunQueriesInWorkgroup",
"Effect": "Allow",
"Action": [
"athena:StartQueryExecution",
"athena:GetQueryExecution",
"athena:GetQueryResults",
"athena:StopQueryExecution"
],
"Resource": "arn:aws:athena:us-east-1:123456789012:workgroup/analytics"
},
{
"Sid": "ReadTableDefinitions",
"Effect": "Allow",
"Action": [
"glue:GetDatabase",
"glue:GetTable",
"glue:GetPartitions"
],
"Resource": [
"arn:aws:glue:us-east-1:123456789012:catalog",
"arn:aws:glue:us-east-1:123456789012:database/weblogs",
"arn:aws:glue:us-east-1:123456789012:table/weblogs/*"
]
},
{
"Sid": "ReadSourceData",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::acme-alb-logs",
"arn:aws:s3:::acme-alb-logs/*"
]
},
{
"Sid": "WriteAndReadQueryResults",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:AbortMultipartUpload"
],
"Resource": [
"arn:aws:s3:::acme-athena-results",
"arn:aws:s3:::acme-athena-results/*"
]
}
]
}
GetQueryResults reads the result file from S3, so the caller needs s3:GetObject on the results location. The reverse matters for access control: anyone with s3:GetObject there can read results even if athena:GetQueryResults is denied. Encrypted data or results also need the matching KMS permissions. To derive the list from your own code, see how to find the IAM actions your AWS SDK JavaScript code needs, or use the IAM policy generator for TypeScript code.
Troubleshooting common Athena errors
InvalidRequestExceptionabout a missing output location. Neither the workgroup nor the request sets a result location. Set one on the workgroup, passoutputLocation, or turn on managed query results. If the workgroup enforces its settings, yourResultConfigurationis ignored.FAILEDwithTABLE_NOT_FOUNDorSCHEMA_NOT_FOUND. The database isn’t set inQueryExecutionContext, or the table is in another catalog or Region. Qualify names asdatabase.table.FAILEDwith an access error on S3. The error usually names the path. Adds3:GetObjectands3:ListBucketfor the data bucket, or KMS permissions for encrypted objects; the steps to troubleshoot AWS IAM access denied errors apply.TooManyRequestsExceptionon start. You hit the concurrent query limit. The SDK retries throttled calls; for batch jobs, limit how many queries you start at once. The guide to configure retries and timeouts in AWS SDK v3 shows the knobs, and monitor AWS service quota usage tracks the limit.- Every value is a string. That’s how
GetQueryResultsreturns cells.convert()maps numbers and booleans from the column type and keepsbigintanddecimalas strings to avoid losing precision. - The first row of data looks like column names. You kept the header row. The helper compares it with
ColumnInfobefore dropping it, so a query whose real first row matches the names isn’t damaged.
Limits of this approach
Paging through GetQueryResults is fine for thousands of rows and slow for millions, because each page is a separate call of at most 1,000 rows. For large results, read the CSV Athena wrote to the output location directly, or use UNLOAD to write Parquet to S3 and process the files. DATA_MANIFEST results only exist for CTAS, UNLOAD and INSERT. Complex types such as arrays, maps and rows come back as strings you parse yourself.
The helper also polls from your process; a Lambda function that waits for a long query pays for the wait. For long or multi-step jobs, let a state machine do the waiting and start a Step Functions execution from TypeScript. Moving an old v2 athena.startQueryExecution().promise() script? The guide to migrate a Node.js app from AWS SDK v2 to v3 covers the pattern, and the boto3 to AWS SDK for JavaScript v3 converter drafts a port of a Python Athena script for review.
ChatWithCloud can answer one-off questions about your AWS account in plain English, and the page on how ChatWithCloud generates and runs AWS SDK code explains the loop. It writes SDK v2 code on the fly, so for a repeatable Athena job, keep this helper in your codebase.
Frequently asked questions
How do I wait for an Athena query to finish in Node.js?
Poll GetQueryExecutionCommand until Status.State is SUCCEEDED, FAILED or CANCELLED, with a delay that grows between calls. There is no built-in waiter for query completion in @aws-sdk/client-athena, so the helper implements one.
Why does GetQueryResults return the column names as the first row?
For SELECT queries the first row of the first page is a header. Skip it, and take names and types from ResultSetMetadata.ColumnInfo instead.
How many rows does GetQueryResults return per call?
Up to 1,000, set with MaxResults or the paginator’s pageSize. Pass NextToken to get the next page.
Can I cancel an Athena query from the SDK?
Yes, with StopQueryExecutionCommand and the query ID. The helper does this when its timeout expires, so an abandoned query doesn’t keep scanning.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud