Skip to content

Running queries

Every generated query is a function that takes a client and its typed parameters.

ts
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:

ts
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:

ts
// 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 columns

Note 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:

ts
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:

ts
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:

  1. AathenaConfig.maxConcurrency, if set.
  2. A live AWS Service Quotas lookup (L-D405C694 for DML, L-FCDFE414 for DDL). This uses @aws-sdk/client-service-quotas, an optional dependency loaded by dynamic import, and needs the servicequotas:GetServiceQuota IAM permission. Without it the lookup fails quietly and the next step applies.
  3. A region-aware conservative fallback: half the AWS-documented default, clamped to [5, 25].
OptionDefaultMeaning
concurrency5a number, or 'auto'
client-required when concurrency: 'auto' and maxConcurrency is unset
kind'dml''dml' or 'ddl', selects which quota to probe
reserveHeadroom1subtracted 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:

ts
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;
  }
}
ClassThrown when
QueryTimeoutErrorthe query exceeded the configured timeout; carries timeoutMs and queryExecutionId
QueryFailedErrorAthena reported a failure; carries athenaErrorMessage
QueryCancelledErrorthe execution was cancelled
ColumnParseErrora returned value did not parse as its declared column type
AathenaErrorthe base class for all of the above

Next

Released under the MIT License.