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.