PostgreSQL with Node.js — A Complete Guide
PostgreSQL with Node.js: connection pooling with node-postgres, CRUD operations, transactions, migrations, and production patterns for relational databases..
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
node-postgres (pg) is the PostgreSQL client for Node.js. It provides connection pooling via pg.Pool, parameterized queries to prevent SQL injection, transaction support via BEGIN/COMMIT/ROLLBACK, and
Think of PostgreSQL as a highly organized filing cabinet, and Node.js as a busy office worker. The worker needs to file or retrieve documents quickly without accidentally knocking over the cabinet or mixing up folders. PostgreSQL handles multiple workers at once by giving each a temporary copy of the folder they're working on, and if two workers try to edit the same document, it makes one wait until the other finishes. Node.js, being single-threaded but event-driven, is like a worker who can juggle multiple tasks by quickly switching between them—but if they try to open too many cabinet drawers at once, they might drop everything. So we use a connection pool (a set of pre-opened drawers) to keep things smooth.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
MongoDB was the default database for Node.js for years, but PostgreSQL has become the dominant choice for production applications in 2026 — and for good reason: ACID compliance, advanced indexing, native JSON support (when you need flexibility), and the strongest query optimizer of any open-source database. This article covers connecting Node.js to PostgreSQL, connection pooling configurations, parameterized queries, transactions, and the production patterns that keep your database fast under load.
Setting Up PostgreSQL with Node.js: The Right Way
Connecting Node.js to PostgreSQL is straightforward, but production setups require more than a basic connection. Start by installing the pg package: npm install pg. Avoid using the pg-native variant unless you have specific performance needs — it adds complexity with native compilation. Use environment variables for credentials, never hardcode them. For connection pooling, create a pool instance that reuses connections efficiently. This reduces overhead from establishing new connections per request. Always handle pool errors globally to avoid silent failures. A common mistake is forgetting to close the pool on application shutdown, leading to hanging connections. Use process.on('SIGINT', ...) to gracefully close the pool.
pool.query() for simple queries; it auto-releases. For transactions, use pool.connect() and release in a finally block.pool.query() for single queries and explicit release for transactions.Schema Design and Migrations: Version Control Your Database
Never create tables manually in production. Use migration tools like node-pg-migrate or knex to version control schema changes. Write migrations as idempotent scripts that can be run multiple times safely. For example, use CREATE TABLE IF NOT EXISTS and ALTER TABLE ... IF EXISTS. Always include rollback scripts for each migration. Store migration files in a migrations directory and run them as part of your deployment pipeline. A common pitfall is running migrations during peak traffic — schedule them during maintenance windows. Use transactions in migrations to ensure atomicity: if one step fails, the entire migration rolls back.
IF EXISTS and IF NOT EXISTS to make migrations rerunnable. This helps in recovery scenarios.CRUD Operations: Parameterized Queries Are Non-Negotiable
When performing CRUD operations, always use parameterized queries to prevent SQL injection. Never concatenate user input into SQL strings. The pg library supports $1, $2 placeholders. For inserts, use RETURNING * to get the inserted row. For updates, use WHERE clauses with parameters. Use ON CONFLICT for upserts. For bulk inserts, use pg-promise or batch inserts with unnest for performance. Avoid ORMs for complex queries — raw SQL gives you full control. However, for simple CRUD, an ORM like TypeORM can reduce boilerplate, but be aware of the N+1 problem.
' OR '1'='1 can compromise your data.Transactions: Atomic Operations for Data Integrity
Transactions ensure that a series of database operations either all succeed or all fail. Use BEGIN, COMMIT, and ROLLBACK manually or use the pool.connect() method to get a client and manage the transaction. Always release the client in a finally block. Handle errors by rolling back and re-throwing. For nested transactions, use savepoints. Avoid long-running transactions — they hold locks and can cause deadlocks. Keep transactions as short as possible. In Node.js, use async/await with try-catch for clean transaction management.
Error Handling and Retries: Graceful Degradation
Database operations can fail due to network issues, deadlocks, or constraint violations. Implement retry logic with exponential backoff for transient errors. Use a library like p-retry or write your own. Distinguish between transient errors (e.g., 40P01 deadlock) and permanent errors (e.g., 23505 unique violation). Log errors with context (query, parameters, error code). Use a centralized error handler that returns appropriate HTTP status codes. For connection errors, consider circuit breaker pattern to avoid overwhelming the database.
Performance Optimization: Indexing and Query Tuning
Indexes are critical for query performance but come with write overhead. Use EXPLAIN ANALYZE to identify slow queries. Create indexes on columns used in WHERE, JOIN, and ORDER BY. Use composite indexes for multi-column filters. Avoid over-indexing — each index slows down writes. Use partial indexes for conditional queries. For full-text search, use GIN indexes. Monitor slow queries with pg_stat_statements. In Node.js, log queries that exceed a threshold (e.g., 100ms) for analysis.
REINDEX and VACUUM to maintain index health. Use pg_repack to rebuild indexes without locks.Connection Pool Tuning: Right-Sizing for Traffic
The default pool size of 10 may not suit your workload. Too few connections cause queuing; too many overwhelm the database. Start with max = 20 and adjust based on concurrent requests and database CPU. Use pgbouncer for connection pooling at the database level, especially with serverless functions. Monitor pool metrics: active, idle, waiting clients. Use pool.totalCount and pool.waitingCount in health checks. Set idleTimeoutMillis to release idle connections after inactivity. For high-traffic apps, consider using pg-pool with custom acquire timeout.
waitingCount is consistently > 0, increase max or optimize queries. If idleCount is high, reduce max to save resources.Security Best Practices: Defense in Depth
Beyond parameterized queries, secure your database with least privilege. Create separate database users for read-only, read-write, and admin roles. Use pg_hba.conf to restrict IP addresses. Encrypt connections with TLS: set ssl: { rejectUnauthorized: true } in the pool config. Never store secrets in code — use a vault or environment variables. Regularly rotate passwords. Use row-level security for multi-tenant apps. Audit queries with pgaudit. In Node.js, sanitize inputs even if using parameterized queries (e.g., strip HTML).
Testing Database Interactions: Integration Tests with Real Data
Unit testing database code requires a real database. Use a test database that is created and destroyed per test suite. Use testcontainers to spin up a disposable PostgreSQL instance in Docker. Write tests that cover CRUD operations, transactions, error handling, and edge cases. Use fixtures to seed data. Avoid mocking the database — it leads to false positives. For CI, use a service container with PostgreSQL. Run tests in parallel with separate databases to avoid conflicts.
Production Monitoring and Alerting: Know Your Database
Monitor PostgreSQL with tools like pg_stat_activity for active queries, pg_stat_statements for query performance, and pg_locks for deadlocks. Set up alerts for long-running queries, connection pool exhaustion, replication lag, and disk space. Use a metrics dashboard (e.g., Grafana) with Prometheus exporters. In Node.js, expose database metrics via a /metrics endpoint for Prometheus. Log slow queries with a custom logger. Have a runbook for common incidents: connection spikes, deadlocks, replication failures.
Scaling PostgreSQL: Read Replicas and Connection Management
As traffic grows, offload read queries to read replicas. Configure the Node.js app to use a separate pool for reads and writes. Use a library like pg-pool with multiple endpoints. For write-heavy workloads, consider sharding or using a distributed database. Use pgbouncer in transaction mode to reduce connection overhead. For serverless, use pg with @aws-sdk/client-rds-data or similar. Implement caching with Redis to reduce database load. Always test scaling strategies under load.
pg_stat_replication to monitor lag.Backup and Disaster Recovery: Plan for the Worst
Regular backups are non-negotiable. Use pg_dump for logical backups and pg_basebackup for physical backups. Automate backups with cron and store them off-site (e.g., S3). Test restores regularly — a backup is only as good as its restore. Use point-in-time recovery (PITR) with WAL archiving. In Node.js, you can trigger backups via a cron job but avoid doing it from the app server. Have a disaster recovery plan with RTO and RPO. Document the restore process and practice it.
Connection Pool Exhaustion Due to Missing Release
pool.connect() to get a client but did not call client.release() in the finally block. When an error occurred, the client was never released back to the pool, eventually exhausting all connections.try-catch-finally block to ensure client.release() is always called. Also implemented a pool acquireTimeout and idleTimeout to automatically recover leaked connections. Added monitoring on pool size.- Always release connections back to the pool, especially in error paths.
- Use
pool.query()for simple queries to auto-release. - Set pool timeouts to prevent permanent leaks.
- Monitor pool utilization in production.
| File | Command / Code | Purpose |
|---|---|---|
| db.js | const { Pool } = require('pg'); | Setting Up PostgreSQL with Node.js |
| migrations | exports.up = async (db) => { | Schema Design and Migrations |
| userRepository.js | const pool = require('./db'); | CRUD Operations |
| transactionExample.js | const pool = require('./db'); | Transactions |
| retry.js | const { Pool } = require('pg'); | Error Handling and Retries |
| indexes.sql | CREATE INDEX idx_users_email ON users (email); | Performance Optimization |
| poolMonitor.js | const pool = require('./db'); | Connection Pool Tuning |
| security.sql | CREATE USER readonly WITH PASSWORD 'strong_password'; | Security Best Practices |
| userRepository.test.js | const { createUser, getUserById } = require('./userRepository'); | Testing Database Interactions |
| monitoring.js | const prometheus = require('prom-client'); | Production Monitoring and Alerting |
| readWritePools.js | const { Pool } = require('pg'); | Scaling PostgreSQL |
| backup.sh | pg_dump -h localhost -U postgres mydb > /backups/mydb_$(date +%Y%m%d_%H%M%S).sql | Backup and Disaster Recovery |
Key takeaways
Interview Questions on This Topic
How do you handle connection pooling in Node.js with PostgreSQL?
pg library's Pool class. Create a pool with a max of 10-20 connections. Always release connections back to the pool after use, typically via pool.query() which auto-releases, or manually with client.release() when using pool.connect(). Set pool timeout and error handling to avoid hanging connections.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Node.js. Mark it forged?
4 min read · try the examples if you haven't