Week 8: Connecting from Application Code

Every query so far has run by hand in psql. This week is where SQL becomes part of a real application — connecting from Node.js, writing queries safely against untrusted input, managing connections efficiently, and understanding what a query builder or ORM actually buys you (and what it costs).

Module 8 of 10 Week 8 of 10 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Connect to PostgreSQL from Node.js and run a query with node-postgres
  • Write parameterized queries that are safe against SQL injection
  • Explain what a connection pool is, and why a new connection per query doesn't scale
  • Decide when a query builder or ORM is worth adopting over raw SQL

1. Connecting with node-postgres

terminal
npm install pg
a first query
const { Pool } = require('pg');

const pool = new Pool({
  host: 'localhost',
  database: 'learn_sql',
  user: 'postgres',
  password: process.env.DB_PASSWORD,
});

async function getCustomer(id) {
  const result = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
  return result.rows[0];
}

result.rows is a plain array of JS objects, one per row, with keys matching the query's column names — this is the same shape whether the query returned one row or ten thousand, which is exactly why the count-checking patterns from earlier weeks (checking result.rows.length) work identically here.

2. Parameterized Queries & SQL Injection

DANGEROUS — never build SQL by concatenating user input
// NEVER do this
const query = "SELECT * FROM customers WHERE email = '" + userInput + "'";
// if userInput is: ' OR '1'='1
// the query becomes: SELECT * FROM customers WHERE email = '' OR '1'='1'
// — which returns every row in the table
always do this — a parameterized query
const result = await pool.query(
  'SELECT * FROM customers WHERE email = $1',
  [userInput]
);

With $1, the query text and the value are sent to Postgres separately — the database parses the query's structure first, then substitutes the parameter purely as data, never as SQL syntax. A malicious value containing ' OR '1'='1 is treated as a literal string to search for, not as SQL that changes the query's meaning — this is the exact same protection Week 12's equivalent lesson in this site's Go course provides through its own driver's placeholder syntax.

3. Connection Pooling

Opening a new database connection is genuinely expensive — a TCP handshake plus Postgres's own connection setup, repeated on every single query, quickly becomes the actual bottleneck in a busy application.

a pool, configured with real limits
const pool = new Pool({
  host: 'localhost',
  database: 'learn_sql',
  max: 20,                     // maximum simultaneous connections
  idleTimeoutMillis: 30000,    // close an idle connection after 30s
  connectionTimeoutMillis: 5000, // fail fast if the pool is exhausted
});

pool.query(...) (used throughout this week) automatically checks out a connection from the pool, runs the query, and returns it — the same pattern as Week 12's connection pool concept in this site's Go course, applied here through node-postgres's own pool implementation rather than database/sql's.

4. Where a Query Builder or ORM Fits

Raw SQL, as written all week, is the clearest and most predictable option — every query does exactly, and only, what it says. A query builder (like Knex) or an ORM (like Prisma) trades some of that directness for less repetition on common patterns.

the same query, three ways
// raw SQL
await pool.query('SELECT * FROM orders WHERE customer_id = $1', [id]);

// a query builder (Knex)
await knex('orders').where('customer_id', id);

// an ORM (Prisma)
await prisma.order.findMany({ where: { customerId: id } });

An ORM earns its cost on straightforward CRUD across many tables, with type generation and less boilerplate. It's worth dropping back to raw SQL — most ORMs support this directly — for anything genuinely complex: a multi-level CTE, a window function, or a query where the generated SQL turns out to perform badly. Everything from Weeks 1–7 stays directly relevant either way; an ORM generates SQL, it doesn't replace the need to understand it.

5. Hands-on Exercise

Hands-on

Build a small Node.js API backed by Postgres

Connect to the customers/orders database from earlier weeks through a real, safe application layer.

Requirements:

  1. A Node.js script (or small Express app) connecting to your Postgres database through a configured connection Pool.
  2. A function fetching a customer by ID, and a function creating a new order — both using parameterized queries ($1, $2, ...), never string concatenation.
  3. A deliberate test: pass a value containing ' OR '1'='1 as a search parameter and confirm it's treated as a literal string, not as SQL — log the actual query executed and the result to see this directly.
  4. Wrap the order-creation logic in a real transaction (BEGIN/COMMIT/ROLLBACK, or node-postgres's client-based transaction pattern) if it involves more than one write.
  5. A short comment stating whether you'd reach for raw SQL, a query builder, or an ORM for this specific small project, and why.
Hint

If you're getting connection errors under load (many queries at once), check your pool's max setting against how many concurrent queries your code is actually issuing — exhausting the pool (more concurrent queries than max allows) causes new queries to wait for a free connection rather than failing outright, which can look like the app hanging rather than a clear error.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a value like ' OR '1'='1 passed into a string-concatenated query change what the query does, but passed into a parameterized query it doesn't?

String concatenation builds the actual SQL text using untrusted input directly, so SQL syntax embedded in that input becomes part of the real query the database parses and executes. A parameterized query sends the query structure and the value separately — Postgres parses the fixed query text first, then substitutes the parameter purely as a literal value, with no way for it to be interpreted as SQL syntax regardless of what it contains.

Q2

Why does opening a fresh connection for every single query not scale, even though it would work correctly for a single query?

Establishing a new connection involves real overhead — a TCP handshake and Postgres's own per-connection setup — that's negligible for one query but becomes the actual bottleneck once an application is issuing many queries per second, since that setup cost is paid again every single time instead of being amortized.

Q3

What does a connection pool's max setting actually control, and what happens when it's reached?

It caps the number of simultaneous connections the pool will open to the database — once that many are checked out and busy, a new query has to wait in line for one to become free rather than the pool opening yet another connection. This bounds how much load the application can put on the database at once, trading some latency under peak load for protecting the database from being overwhelmed by unbounded connections.

Q4

According to this week's framework, when would raw SQL be the better choice than an ORM, even in a project that uses an ORM for most of its queries?

For anything genuinely complex — a multi-level CTE, a window function, or any query where the ORM's generated SQL turns out to perform poorly — dropping back to raw SQL (which most ORMs support directly) is usually clearer and more controllable than fighting the ORM's abstraction to express the same thing. An ORM earns its keep on straightforward, repetitive CRUD; it isn't meant to be the only tool for every query.