Build a Node.js backend that analyzes hundreds of millions of Parquet and CSV records with SQL — no separate server required
When the requirement to add data analytics functionality to the backend arises, the first options most people think of are data warehouses like Redshift or BigQuery. I was the same way for a while. But after getting everything set up, there comes a moment where you wonder, "Do I really need all this infrastructure just to run one query?" DuckDB can be the answer to that concern. With no server, no cluster, and no separate process, a single pnpm add @duckdb/node-api lets you analyze hundreds of millions of rows of Parquet files with SQL right inside a Node.js process.
This post covers everything from the concept of an in-process OLAP database, to the concrete steps for integrating DuckDB into a TypeScript backend using the latest recommended package @duckdb/node-api, to the concurrency, security, and memory issues you actually encounter in production — including the pattern of querying Parquet files on S3 directly with SQL. Code examples are written for NestJS, but they apply equally to Express or Fastify.
Core Concepts
Why DuckDB Is "SQLite for Analytics"
When you first hear about DuckDB, you might think "another database?" — but there's one key difference from existing databases. There is no separate server process. Like SQLite, it links into your application as a library. With PostgreSQL you go app server → network → DB server, but with DuckDB it's just a function call. There is zero network latency.
What sets it apart from SQLite is that it is specialized for OLAP. SQLite is an OLTP-friendly, row-oriented storage and processing engine, whereas DuckDB is a columnar vectorized execution engine. According to DuckDB's official TPC-H benchmarks, the Q1 aggregation query over 150 million rows completes in under one second on a standard laptop.
Choosing a Node.js Client — Watch Out for the Legacy Trap
There are two packages on npm: duckdb and @duckdb/node-api. The bottom line is that the duckdb package is deprecated. v1.4.x was the last release, and distribution was discontinued starting with v1.5.x.
| Package | Status | API Style |
|---|---|---|
duckdb |
Deprecated — do not use | Callback-based legacy |
@duckdb/node-api |
Currently recommended (v1.5.4+) | Native async/await |
@duckdb/node-bindings |
Low-level C API bindings | Used internally by node-api |
Stack Overflow and older blog posts are full of examples based on the duckdb package. I set things up using those examples myself, only to discover the deprecation notice later and have to migrate.
Understanding the Execution Model and Concurrency
Here is the concurrency model you absolutely need to know for production environments.
Three key points:
- Multiple readers can run concurrently: MVCC allows multiple connections to read at the same time
- Only one writer at a time: While a write connection holds the exclusive lock, other connections must wait
- Multiple processes are not supported: Running multiple processes in Node.js cluster mode that share the same
.duckdbfile will produce file lock errors
If you're primarily doing reads for analytics, this is almost never an issue. If writes are frequent, it's worth designing a structure where a single writer process serializes all writes.
Practical Application
Installation and Basic Setup
pnpm add @duckdb/node-apiDuckDB automatically downloads platform-specific native binaries when installed via npm. macOS, Linux (x64/arm64), and Windows are all supported.
The simplest example, starting in in-memory mode:
// src/analytics/duckdb.client.ts
import { DuckDBInstance } from '@duckdb/node-api';
async function quickStart() {
// ':memory:' — in-memory DB, data is lost when the process exits
const instance = await DuckDBInstance.create(':memory:');
const conn = await instance.connect();
const result = await conn.run(`
SELECT 42 AS answer, 'DuckDB works!' AS message
`);
const rows = await result.fetchAllRows();
console.log(rows);
// [{ answer: 42, message: 'DuckDB works!' }]
await conn.close();
await instance.close();
}
quickStart();If you want a persistent file-based DB, pass a file path instead of ':memory:':
const instance = await DuckDBInstance.create('./analytics.duckdb');Path Validation Utility — Prepare This First
This is the validation function used in all file path examples that follow. When inserting Parquet or CSV file paths into SQL strings, you cannot bind the FROM clause path as a Prepared Statement parameter. Instead, use a whitelist validation that simultaneously blocks both path traversal outside the allowed directory and SQL special characters.
// src/analytics/path-validator.ts
import path from 'node:path';
export const DATA_BASE_DIR = path.resolve(process.env.DATA_BASE_DIR ?? './data');
export function validateDataPath(input: string): string {
// Reject any characters other than alphanumerics, slashes, hyphens, underscores, dots, and globs (*)
// Single quotes (') and other SQL special characters are blocked at this stage
if (!/^[\w\-./\*]+$/.test(input)) {
throw new Error('Path contains disallowed characters');
}
const resolved = path.resolve(DATA_BASE_DIR, input);
if (!resolved.startsWith(DATA_BASE_DIR + path.sep) && resolved !== DATA_BASE_DIR) {
throw new Error('Path is outside the allowed directory');
}
return resolved;
}Querying Parquet Files Directly
No need to pre-load files — reference them directly in SQL:
// src/analytics/analytics.service.ts
import { validateDataPath } from './path-validator';
async getSalesSummary(year: number) {
// Even for number types, validate that it's an integer within range
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw new Error('Invalid year');
}
// Query multiple Parquet files at once with a glob pattern
const safePath = validateDataPath(`sales/${year}/*.parquet`);
const result = await this.conn.run(`
SELECT
region,
SUM(amount) AS total_sales,
COUNT(*) AS order_count,
AVG(amount) AS avg_order_value
FROM '${safePath}'
WHERE status = 'completed'
GROUP BY region
ORDER BY total_sales DESC
`);
return result.fetchAllRows();
}
async analyzeCSV(relativePath: string) {
const safePath = validateDataPath(relativePath);
const result = await this.conn.run(`
SELECT *
FROM read_csv_auto('${safePath}', header = true)
LIMIT 100
`);
return result.fetchAllRows();
}Safely Binding Filter Conditions with Prepared Statements
If user input appears in WHERE clause values (not file paths), always use Prepared Statements:
async getTopProducts(categoryId: number, limit: number) {
const safePath = validateDataPath('orders/*.parquet');
const stmt = await this.conn.prepare(`
SELECT
product_id,
product_name,
SUM(quantity) AS total_sold
FROM '${safePath}'
WHERE category_id = $categoryId
GROUP BY product_id, product_name
ORDER BY total_sold DESC
LIMIT $limit
`);
try {
const result = await stmt.run({ categoryId, limit });
return await result.fetchAllRows();
} finally {
await stmt.close();
}
}If you run the same query repeatedly, caching the Prepared Statement at the instance level saves on parsing and planning costs.
Integrating as a NestJS Module
// src/analytics/duckdb.provider.ts
import { DuckDBInstance, DuckDBConnection } from '@duckdb/node-api';
import { ConfigService } from '@nestjs/config';
export const DUCKDB_INSTANCE = 'DUCKDB_INSTANCE';
export const DUCKDB_CONNECTION = 'DUCKDB_CONNECTION';
export const DuckDBProviders = [
{
provide: DUCKDB_INSTANCE,
useFactory: async (config: ConfigService): Promise<DuckDBInstance> => {
const dbPath = config.get<string>('DUCKDB_PATH', ':memory:');
const instance = await DuckDBInstance.create(dbPath);
// threads and memory_limit are configured via SET commands
// The second-argument signature of DuckDBInstance.create can vary across versions,
// so standard SQL SET commands are the safest and most version-independent approach
const configConn = await instance.connect();
try {
const threads = parseInt(config.get<string>('DUCKDB_THREADS', '4'), 10);
const memLimit = config.get<string>('DUCKDB_MEMORY_LIMIT', '2GB');
await configConn.run(`SET threads = ${threads}`);
await configConn.run(`SET memory_limit = '${memLimit}'`);
} finally {
await configConn.close();
}
return instance;
},
inject: [ConfigService],
},
{
provide: DUCKDB_CONNECTION,
useFactory: async (instance: DuckDBInstance): Promise<DuckDBConnection> => {
return instance.connect();
},
inject: [DUCKDB_INSTANCE],
},
];// src/analytics/analytics.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DuckDBProviders } from './duckdb.provider';
import { AnalyticsService } from './analytics.service';
import { AnalyticsController } from './analytics.controller';
@Module({
imports: [ConfigModule],
providers: [...DuckDBProviders, AnalyticsService],
controllers: [AnalyticsController],
exports: [AnalyticsService],
})
export class AnalyticsModule {}// src/analytics/analytics.service.ts
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { DuckDBInstance, DuckDBConnection } from '@duckdb/node-api';
import { DUCKDB_INSTANCE, DUCKDB_CONNECTION } from './duckdb.provider';
import { validateDataPath } from './path-validator';
@Injectable()
export class AnalyticsService implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(DUCKDB_INSTANCE) private readonly instance: DuckDBInstance,
@Inject(DUCKDB_CONNECTION) private readonly conn: DuckDBConnection,
) {}
async onModuleInit() {
// Load the httpfs extension once during module initialization
// INSTALL only performs an actual download on first run; subsequent calls are no-ops
await this.conn.run(`INSTALL httpfs; LOAD httpfs;`);
// S3 credentials: CREDENTIAL_CHAIN searches environment variables, IAM Roles,
// and instance profiles in order, so no keys need to be exposed in the code
await this.conn.run(`
CREATE OR REPLACE SECRET aws_s3 (
TYPE S3,
PROVIDER CREDENTIAL_CHAIN
);
`);
}
async onModuleDestroy() {
try {
await this.conn.close();
} finally {
await this.instance.close();
}
}
async getDailySummary(date: string) {
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error('Invalid date format');
}
const safePath = validateDataPath(`events/${date}/*.parquet`);
const stmt = await this.conn.prepare(`
SELECT
DATE_TRUNC('hour', event_time) AS hour,
event_type,
COUNT(*) AS event_count
FROM '${safePath}'
WHERE event_time >= $startDate::TIMESTAMP
AND event_time < $startDate::TIMESTAMP + INTERVAL 1 DAY
GROUP BY 1, 2
ORDER BY 1, 2
`);
try {
const result = await stmt.run({ startDate: date });
return await result.fetchAllRows();
} finally {
await stmt.close();
}
}
}Querying Remote S3 Parquet Directly
Since httpfs is loaded and authenticated in onModuleInit, subsequent queries can use S3 paths directly:
async queryS3Parquet(s3Prefix: string) {
// s3Prefix example: 'logs/2026/07'
// Recommend validating against an allowed prefix whitelist for S3 paths
if (!/^[\w\-./]+$/.test(s3Prefix)) {
throw new Error('Invalid S3 path');
}
const result = await this.conn.run(`
SELECT
user_id,
COUNT(*) AS page_views,
COUNT(DISTINCT session_id) AS sessions
FROM 's3://my-analytics-bucket/${s3Prefix}/*.parquet'
WHERE event_type = 'page_view'
GROUP BY user_id
HAVING page_views > 100
ORDER BY page_views DESC
LIMIT 1000
`);
return result.fetchAllRows();
}For AWS credentials, CREDENTIAL_CHAIN automatically searches AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment variables → ~/.aws/credentials → EC2/ECS IAM Role in that order, so there is no need to expose keys directly in the code.
Processing Large Results Page by Page
Loading millions of rows at once with fetchAllRows() causes memory issues. Here is a pattern using a LIMIT/OFFSET-based generator to process data in chunks:
async *streamResultByPage(
relativePath: string,
pageSize = 10_000,
): AsyncGenerator<Record<string, unknown>[]> {
const safePath = validateDataPath(relativePath);
let offset = 0;
while (true) {
const stmt = await this.conn.prepare(`
SELECT * FROM '${safePath}'
LIMIT $pageSize OFFSET $offset
`);
try {
const result = await stmt.run({ pageSize, offset });
const rows = await result.fetchAllRows() as Record<string, unknown>[];
if (rows.length === 0) break;
yield rows;
if (rows.length < pageSize) break;
offset += pageSize;
} finally {
await stmt.close();
}
}
}Usage example:
for await (const page of service.streamResultByPage('large-dataset.parquet')) {
await processBatch(page);
}If you need chunk-level streaming, a result.fetchChunk() API is also available. For column-vector access within chunks, refer to the official documentation. For most batch processing scenarios, the LIMIT/OFFSET generator pattern above is more practical in terms of readability and safety.
CSV → Parquet Conversion Pipeline
Receiving a CSV with DuckDB and saving it as Parquet dramatically improves subsequent query speeds:
async convertCSVToParquet(csvRelPath: string, outRelPath: string) {
const csvPath = validateDataPath(csvRelPath);
const outPath = validateDataPath(outRelPath);
await this.conn.run(`
COPY (
SELECT * FROM read_csv_auto('${csvPath}', header = true)
) TO '${outPath}'
(FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 100000)
`);
}Pros and Cons Analysis
Summary of Advantages
| Advantage | Description |
|---|---|
| Zero infrastructure | No DB server or cluster needed. Start with a single package install |
| Direct file querying | SELECT … FROM '*.parquet' format — analyze directly without ETL |
| Excellent analytical performance | Vectorized execution scans 100M+ rows in seconds on a single node |
| Larger-than-memory | Data exceeding memory is handled by spilling to disk |
| Full standard SQL support | Window functions, CTEs, LATERAL, PIVOT — all supported |
| Local development friendly | Run the exact same code locally as in production |
Limitations and Drawbacks
| Limitation | Details | Alternative |
|---|---|---|
| Concurrent write restriction | Only a single writer is allowed | Appender API + serialization queue |
| Unsuitable for OLTP | Frequent row-level INSERT/UPDATE is slow | Use PostgreSQL for OLTP, DuckDB for analytics |
| Multi-process restriction | File lock conflicts | Single process or MotherDuck |
| No horizontal scaling | Single-node design | MotherDuck, DuckLake |
| Serverless memory limits | Watch out for Lambda 128MB caps etc. | Set memory limits + page-based processing |
Common Mistakes in Practice
1. Using the legacy package
Searching for duckdb on npm and installing it gives you the deprecated package. Always use @duckdb/node-api.
2. Incorrect security handling for FROM clause paths
path.basename() only prevents directory traversal and does not block SQL injection. Because FROM clause paths cannot be bound as Prepared Statement parameters, you must use both an allowlist of permitted characters and a directory prefix check together:
// Dangerous — blocks neither path traversal nor SQL special characters
await conn.run(`SELECT * FROM '${userInput}'`);
// Wrong approach — path.basename only prevents directory traversal;
// if userInput contains a ' character, the SQL is still broken
const name = path.basename(userInput);
await conn.run(`SELECT * FROM './data/${name}'`);
// Correct approach — validateDataPath validates both allowed characters and directory boundaries simultaneously
const safePath = validateDataPath(userInput);
await conn.run(`SELECT * FROM '${safePath}'`);3. Prepared Statement resource leaks
stmt.close() must always be wrapped in try/finally. If an exception occurs during query execution, close() will not be called and resources will leak.
4. Overusing fetchAllRows() for large results
Loading millions of rows at once with fetchAllRows() causes memory issues. Use the LIMIT/OFFSET generator pattern shown above.
5. Calling INSTALL httpfs repeatedly inside API functions
INSTALL only performs an actual download on the first run and is a no-op thereafter, but LOAD must be called once per connection. Placing this logic inside API functions means it runs on every call, and the intent is completely invisible in the code. The correct pattern is to consolidate this in onModuleInit.
Closing Thoughts
To summarize: DuckDB is an in-process OLAP engine optimized for analytical workloads. A single pnpm add @duckdb/node-api embeds it in a Node.js backend, and you can query Parquet and CSV files directly with SQL and no ETL. It is especially powerful for read-heavy analytics APIs, log aggregation, and report generation scenarios. Conversely, if you need OLTP, concurrent writes from multiple processes, or multi-node horizontal scaling, it is not the right fit — in those cases, consider alternatives like PostgreSQL or MotherDuck.
If you're starting today, here are three suggested steps:
- Validate quickly with in-memory mode after installation: Install with
pnpm add @duckdb/node-api, then query your existing CSV or Parquet files against an in-memory DB to verify performance firsthand. - Integrate as a NestJS module: Use the code examples above as the basis for building an
AnalyticsModuleand attaching it to your existing services. You can run PostgreSQL and DuckDB in parallel and switch over after validation. - Apply direct S3 file querying: Use the
httpfsextension withCREDENTIAL_CHAINto query Parquet files directly from an S3 bucket — this pattern can eliminate an entire ETL pipeline.
References
- DuckDB Official Website
- Node.js Client (Neo) Official Docs
- Node.js API LTS Docs
- DuckDB Node Neo Client Announcement Blog
- @duckdb/node-api npm package
- duckdb-node-neo GitHub
- DuckDB Parquet Read/Write Official Docs
- DuckDB Concurrency Official Docs
- DuckDB Performance Guide
- DuckDB v1.4.1 LTS Announcement
- DuckDB v1.5.0 Announcement
- duckdb-node-neo DeepWiki Performance Optimization
- Analyzing X Data with Node.js + DuckDB (MotherDuck)
- Using DuckDB in AWS Lambda
- Custom DuckDB WASM Builds for Cloudflare Workers
- Embedded Databases 2026 Comparison (Kestra)
- DuckDB in Production Guide
- ETL Pipeline Example with dlt + SQLMesh + DuckDB