Running queries
Every generated query is a function that takes a client and its typed parameters.
import { createClient } from 'aathena';
import { byStatus } from './generated';
const athena = createClient();
const result = await byStatus(athena, { status: 'active', rowLimit: 99 });createClient() with no argument finds aathena.config.json by walking up from the current working directory. Pass a config to override it, which is what you want in tests or when the project root is not on disk:
import { createClient } from 'aathena';
// createClient() with no argument reads aathena.config.json from the project
// root. Pass a config to override it - useful in tests, or when the project
// root is not on disk (bundled Lambda deploys).
const athena = createClient({
region: 'us-east-1',
database: 'analytics',
workgroup: 'primary',
outputLocation: 's3://my-athena-results/output/',
});Rows are typed from the catalog
Column names are emitted exactly as Glue reports them - no camelCasing, no renaming - so a row object matches the table it came from. Scalars land as native TypeScript, and Parquet/ORC arrays, maps and structs are parsed back recursively, even nested:
// Glue: order_id bigint, placed_at timestamp, tags array<varchar>,
// metadata map<string,int>, address struct<city:string>,
// items array<struct<qty:int>>, partitioned by dt string
import { createClient } from 'aathena';
import { detail } from './generated';
const athena = createClient();
const result = await detail(athena, { dt: '2026-08-26', rowLimit: 33 });
const row = result.rows[0];
row.order_id; // bigint | null
row.placed_at; // Date | null
row.tags; // string[] | null
row.metadata; // Record<string, number> | null
row.address?.city; // string | undefined - struct field access
row.items?.[0].qty; // number | undefined - nested array of struct
row.dt; // string - partition keys are the only NOT NULL columnsNote the nullability. Athena guarantees NOT NULL only for partition keys, so dt is the one column above typed without | null. That is a rule worth internalising early: it is what stops row.address.city from compiling and sends you to row.address?.city instead.
See Type mapping for the full table.
Retries
client.query() retries StartQueryExecution with exponential backoff and full jitter when Athena answers TooManyRequestsException or CONCURRENT_QUERY_LIMIT_EXCEEDED, up to 6 attempts. This applies to every call - generated or inline - including the tasks dispatched by parallel().
Debugging: export the rendered SQL
Pass { exportTo: <path> } as the optional third argument to write the rendered SQL, with parameter values substituted, to disk:
import { createClient } from 'aathena';
import { byStatus } from './generated';
const athena = createClient();
// The rendered SQL, with parameter values substituted, is written to disk.
// The query still runs; missing parent directories are created and the file
// is overwritten on each call.
await byStatus(
athena,
{ status: 'active', rowLimit: 99 },
{ exportTo: './debug/by-status.sql' },
);The query still executes. Missing parent directories are created, and the file is overwritten on each call.
Running queries concurrently
parallel() runs several queries at once under a bounded cap that respects Athena's per-account active-DML quota. Tasks are thunks rather than promises, so the helper controls when each query actually starts:
import { createClient, parallel } from 'aathena';
import { active, byStatus } from './generated';
const athena = createClient();
// Tasks are thunks, not promises, so parallel() decides when each query
// actually starts rather than racing them all at import time.
const [users, events] = await parallel(
[
() => active(athena, { minAge: 18, rowLimit: 99 }),
() => byStatus(athena, { status: 'active', rowLimit: 99 }),
],
{ concurrency: 'auto', client: athena },
);With concurrency: 'auto', the cap is resolved in this order:
AathenaConfig.maxConcurrency, if set.- A live AWS Service Quotas lookup (
L-D405C694for DML,L-FCDFE414for DDL). This uses@aws-sdk/client-service-quotas, an optional dependency loaded by dynamic import, and needs theservicequotas:GetServiceQuotaIAM permission. Without it the lookup fails quietly and the next step applies. - A region-aware conservative fallback: half the AWS-documented default, clamped to
[5, 25].
| Option | Default | Meaning |
|---|---|---|
concurrency | 5 | a number, or 'auto' |
client | - | required when concurrency: 'auto' and maxConcurrency is unset |
kind | 'dml' | 'dml' or 'ddl', selects which quota to probe |
reserveHeadroom | 1 | subtracted from the resolved quota |
mode | 'all' | 'all' rejects on the first failure; 'allSettled' returns per-task settlements |
Errors
Everything aathena throws extends AathenaError, so you can catch the specific cases you handle and let the rest fall through:
import {
AathenaError,
QueryFailedError,
QueryTimeoutError,
createClient,
} from 'aathena';
import { byStatus } from './generated';
const athena = createClient();
try {
const result = await byStatus(athena, { status: 'active', rowLimit: 99 });
console.log(result.rows.length);
} catch (err) {
if (err instanceof QueryTimeoutError) {
console.log(`Timed out after ${err.timeoutMs}ms: ${err.queryExecutionId}`);
} else if (err instanceof QueryFailedError) {
console.log(`Athena error: ${err.athenaErrorMessage}`);
} else if (err instanceof AathenaError) {
// QueryCancelledError, ColumnParseError, or anything else aathena threw
console.log(`aathena error (${err.name}): ${err.message}`);
} else {
throw err;
}
}| Class | Thrown when |
|---|---|
QueryTimeoutError | the query exceeded the configured timeout; carries timeoutMs and queryExecutionId |
QueryFailedError | Athena reported a failure; carries athenaErrorMessage |
QueryCancelledError | the execution was cancelled |
ColumnParseError | a returned value did not parse as its declared column type |
AathenaError | the base class for all of the above |
Next
- Configuration - every field of
aathena.config.json - Type mapping - Athena types to TypeScript