close
homesponsors ⭐
 
   
🔍

Database locking with raw SQL (FOR UPDATE)

August 11, 2026

Two concurrent transactions that each SELECT a row, change it in memory, then UPDATE can lose an update or claim the same resource twice. Postgres does not serialize that read-modify-write path unless you ask for a row lock.

SELECT … FOR UPDATE takes a pessimistic row lock until the transaction commits or rolls back. Other transactions that try to lock the same row wait, fail immediately (NOWAIT), or move on (SKIP LOCKED).

This post covers the race, FOR UPDATE / NOWAIT / SKIP LOCKED in raw SQL, the same flow with node-pg, a short contrast with optimistic locking, and pitfalls.

Prerequisites

The race without a lock

Account balance starts at 100. Two workers each intend to debit 10. Done one after the other, that is 100 → 90 → 80.

What goes wrong is read → compute in the app → write an absolute value, with no row lock:

Worker A Worker B
-------- --------
reads balance = 100
reads balance = 100
next = 100 - 10 = 90 next = 100 - 10 = 90
UPDATE SET balance = 90
COMMIT (balance is now 90)
UPDATE SET balance = 90
COMMIT (balance still 90)

Both workers applied “set to 90” based on the same stale read. The second debit never subtracted from 90, so the stored balance is 90 instead of the intended 80.

UPDATE … SET balance = balance - 10 in SQL would avoid this particular race. The bug appears when the app does read → compute → write an absolute value. Same pattern shows up when two workers claim one pending job with SELECT … WHERE status = 'pending' LIMIT 1 and then UPDATE … SET status = 'claimed'.

SELECT … FOR UPDATE

Lock the row inside an explicit transaction. The lock lives only until COMMIT or ROLLBACK.

BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
UPDATE accounts
SET balance = balance - 10
WHERE id = 1;
COMMIT;

While the first transaction holds the lock, a second SELECT … FOR UPDATE on the same row waits. After commit, the second transaction sees the updated balance.

NOWAIT

Fail immediately if the row is already locked instead of waiting:

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE NOWAIT;

Postgres raises an error (could not obtain lock on row). Useful when you prefer to retry or return “busy” rather than block a request thread.

SKIP LOCKED

Skip rows that other transactions already locked. Typical for job queues: each worker claims a different pending row without waiting.

BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
UPDATE jobs
SET status = 'claimed', claimed_at = now()
WHERE id = $1;
COMMIT;

An empty result can mean “no pending jobs” or “every pending job is locked by another worker right now.” Decide whether to treat that as idle or as a short retry.

With node-pg

Use one client per transaction (pool.connect()), not interleaved queries on a shared pool client:

import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function debit(accountId, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT balance
FROM accounts
WHERE id = $1
FOR UPDATE`,
[accountId]
);
if (!rows[0] || rows[0].balance < amount) {
throw new Error('Insufficient funds');
}
await client.query(
`UPDATE accounts
SET balance = balance - $1
WHERE id = $2`,
[amount, accountId]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

Claim a job with SKIP LOCKED the same way: BEGINSELECT … FOR UPDATE SKIP LOCKED LIMIT 1UPDATECOMMIT on that client.

Optimistic vs pessimistic

ApproachIdea
Pessimistic (FOR UPDATE)Lock the row before changing it; others wait or skip
Optimistic (version column)UPDATE … WHERE id = $1 AND version = $2; retry if zero rows updated

Reach for FOR UPDATE when contention is expected and you must not double-apply (money, inventory, exclusive claims). Optimistic locking avoids holding locks during long work but needs a retry loop.

Redis locks and Postgres advisory locks are out of scope here - different tools for different coordination problems.

Pitfalls

  • No transaction, no lock - FOR UPDATE outside a transaction is released as soon as the statement finishes; wrap BEGIN/COMMIT.
  • Keep the critical section short - do not hold the lock while calling external APIs; claim/update, then work after commit when you can.
  • Lock only the rows you need - filter with indexed WHERE / LIMIT so you do not lock a large scan.
  • SKIP LOCKED empty set - not always “queue empty”; candidates may all be locked.
  • NOWAIT is an error - handle the lock-not-available SQLSTATE in your app.

Supabase basics with Node.js

June 3, 2026

Supabase is an open-source backend platform built around managed PostgreSQL. You get a database, auto-generated REST APIs (via PostgREST), Auth, file Storage, Realtime subscriptions, and Edge Functions - with a dashboard and SQL editor on top.

Compared to running Postgres yourself, Supabase adds hosted infra, API layers, and product features without you wiring them up. Compared to an ORM-only stack, you often talk to Postgres through the Supabase client or SQL, with RLS (row level security) enforcing access at the database layer.

Prerequisites

  • Supabase account (free tier is enough for learning)
  • Node.js version 26
  • @supabase/supabase-js installed (npm i @supabase/supabase-js)

Create a project and database

  1. In the Supabase dashboard, choose New project, pick a region, and set the database password.
  2. Wait until the project is ready.
  3. Open SQL Editor and run the schema below.

The Table Editor is fine for quick experiments; SQL Editor keeps schema reproducible in git and reviews.

create table if not exists public.todos (
id bigint generated always as identity primary key,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
create or replace function public.list_open_todos()
returns setof public.todos
language sql
security definer
set search_path = public
as $$
select * from public.todos where done = false order by id;
$$;
create or replace function public.mark_todo_done(todo_id bigint)
returns setof public.todos
language sql
security definer
set search_path = public
as $$
update public.todos set done = true where id = todo_id returning *;
$$;
grant execute on function public.list_open_todos() to service_role;
grant execute on function public.mark_todo_done(bigint) to service_role;

This schema skips RLS setup because this post uses the secret key from Node.js, which bypasses RLS. For browser or mobile clients, use the publishable key instead - it obeys Row Level Security when you enable it on tables.

list_open_todos is a Postgres function exposed as an RPC endpoint.

Postgres functions default to security invoker: they run with the caller's permissions, so RLS applies as that role. security definer runs the function as its owner (often a privileged database role), not as the caller.

API keys and connection

On the project Overview tab you will find:

  • Project URL (https://<ref>.supabase.co) - for SUPABASE_URL
  • Publishable key (sb_publishable_..., legacy anon) - for browsers and mobile apps where RLS should limit access; this post does not use it

For Node.js backends, use a secret key from Project SettingsAPI Keys:

  • Secret key(s) (sb_secret_...) - full data access, bypasses RLS; trusted servers only, never in client bundles or public repos. Replaces the legacy service_role key.
  • Reveal or create a secret key in the dashboard (legacy projects: Legacy anon, service_role API keys tab → service_role).

The Supabase JS client talks to PostgREST and can query tables (from) and call registered functions (rpc) only after they exist. It does not run DDL (create table, create function, grant, and similar), so apply the schema in the SQL Editor (or via Supabase CLI migrations in larger projects).

Store values in environment variables:

SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_SECRET_KEY=your-secret-key

Client setup

Create a client with the project URL and secret key. Read values from the environment in real apps; never commit the secret key.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);

Every table you create in the public schema is available as supabase.from('<table>'). Custom SQL functions are called with supabase.rpc('<function_name>').

Insert data

Insert one or more rows and return the created records with .select().

const { data, error } = await supabase
.from('todos')
.insert([
{ title: 'Learn Supabase client', done: false },
{ title: 'Run RPC example', done: false },
{ title: 'Ship demo', done: true },
])
.select();
if (error) {
throw new Error(error.message);
}
console.log(data);

Read, update, and delete

Select with filters and ordering:

const { data, error } = await supabase
.from('todos')
.select('*')
.eq('done', false)
.order('id');

Update matching rows:

const { data, error } = await supabase
.from('todos')
.update({ done: true })
.eq('id', 1)
.select();

Delete matching rows:

const { error } = await supabase
.from('todos')
.delete()
.eq('id', 1);

Chain .eq(), .in(), .limit(), and other filter helpers the same way across operations.

RPC

Call a database function by name. Our list_open_todos() returns open todos without repeating filter logic in the app.

const { data, error } = await supabase.rpc('list_open_todos');
if (error) {
throw new Error(error.message);
}
console.log(data);

Functions with parameters map to a second argument:

await supabase.rpc('mark_todo_done', { todo_id: 42 });

Define parameters in SQL (todo_id bigint) and grant execute to service_role when calling RPC from a secret-key backend.

What else matters

  • Row Level Security (RLS) - When enabled on a table, Postgres denies access until policies allow it. The publishable key is subject to RLS; the secret key is not. Add policies per role (anon, authenticated) and operation when you turn RLS on.
  • Auth - Supabase Auth stores users in auth.users. After sign-in, the client JWT includes the authenticated role so policies can use auth.uid() for per-user rows.
  • Migrations - Avoid changing production only via the dashboard. Track SQL in versioned migration files and apply with the Supabase CLI (supabase db push, linked projects) so environments stay aligned.
  • Generated types - Run supabase gen types typescript --project-id <ref> to emit TypeScript types from your schema for a type-safe client.
  • Storage - S3-compatible buckets for files (images, PDFs) with bucket policies analogous to RLS.
  • Realtime - Subscribe to insert/update/delete on tables or channels for live UI updates.
  • Edge Functions - Deno functions for webhooks, light API logic, or tasks that should not live in the database.
  • Local dev - supabase init and supabase start spin up local Postgres and services; useful for offline work, while cloud projects are fastest for a first tutorial.

RAG with OpenAI Embeddings, pgvector and LangChain

June 2, 2026

Retrieval-Augmented Generation (RAG) is a practical pattern: store knowledge as embeddings, retrieve the most relevant chunks with semantic search, then generate an answer grounded in that context.

This guide shows an end-to-end RAG flow with LangChain, OpenAI embeddings, PostgreSQL + pgvector, and an LCEL answer chain. For LangChain basics, see the LangChain overview post. For loaders and splitter choice, see the loaders and chunking post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • PostgreSQL with pgvector extension enabled
  • npm packages: @langchain/pgvector, @langchain/openai, @langchain/core, @langchain/textsplitters, langchain, pg
npm i @langchain/pgvector @langchain/openai @langchain/core @langchain/textsplitters langchain pg

What are embeddings?

Embeddings are numeric vectors that represent the semantic meaning of text. Similar text should produce vectors that are close in vector space.

In this pipeline:

  • Split source documents into chunks
  • Embed chunks with OpenAIEmbeddings and store them in pgvector via PGVectorStore
  • Embed the user question at query time and retrieve nearest chunks with a LangChain retriever
  • Pass retrieved context into an LCEL chain that calls ChatOpenAI

Chunk documents

Chunking makes retrieval more precise. Instead of embedding one large document, split it into smaller overlapping parts. Start with chunkSize: 800 and chunkOverlap: 120, then adjust based on your document style and answer quality.

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const docs = await splitter.createDocuments(
['RAG combines retrieval and generation. Store chunks as vectors and fetch similar chunks at query time.'],
[{ source: 'notes.md' }]
);

Store chunks in pgvector

Use PGVectorStore from @langchain/pgvector. It creates the table if needed, embeds documents, and stores vectors with metadata.

import pg from 'pg';
import { OpenAIEmbeddings } from '@langchain/openai';
import { PGVectorStore } from '@langchain/pgvector';
const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' });
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const vectorStore = await PGVectorStore.initialize(embeddings, {
pool,
tableName: 'rag_documents',
columns: {
idColumnName: 'id',
vectorColumnName: 'vector',
contentColumnName: 'content',
metadataColumnName: 'metadata'
},
distanceStrategy: 'cosine'
});
await vectorStore.addDocuments(docs);

Retrieve context

Turn the vector store into a retriever to fetch the top-k relevant chunks for a question:

const retriever = vectorStore.asRetriever({ k: 4 });
const chunks = await retriever.invoke('How does pgvector semantic search work?');

RAG chain with LCEL

Wire retrieval and generation with LCEL. The retriever supplies context; the model answers from that context only.

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { ChatOpenAI } from '@langchain/openai';
const prompt = ChatPromptTemplate.fromMessages([
[
'system',
'Answer only from the provided context. If context is insufficient, say you need more data.'
],
['human', 'Context:\n{context}\n\nQuestion: {question}']
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' });
const formatDocs = (documents) =>
documents.map((doc) => doc.pageContent).join('\n\n---\n\n');
const chain = RunnableSequence.from([
{
context: retriever,
question: new RunnablePassthrough()
},
(input) => ({
context: formatDocs(input.context),
question: input.question
}),
prompt,
model,
new StringOutputParser()
]);
const answer = await chain.invoke('How does pgvector semantic search work?');
console.log(answer);
2023

Postgres and Redis containers with Docker Compose

February 26, 2023

For Compose basics, see the Docker Compose overview. This post covers Postgres and Redis images with UI tools.

Prerequisites

  • Docker Compose installed

Configuration

The following configuration spins up Postgres and Redis containers with UI tools (Pgweb and Redis Commander).

Connection strings for Postgres and Redis are redis://localhost:6379 and postgres://username:password@localhost:5435/database-name.

Pgweb and Redis Commander are available at http://localhost:8085 and http://localhost:8081 links.

# docker-compose.yml
services:
postgres:
image: postgres:alpine
environment:
POSTGRES_DB: database-name
POSTGRES_PASSWORD: password
POSTGRES_USER: username
ports:
- 5435:5432
restart: on-failure:3
pgweb:
image: sosedoff/pgweb
depends_on:
- postgres
environment:
PGWEB_DATABASE_URL: postgres://username:password@postgres:5432/database-name?sslmode=disable
ports:
- 8085:8081
restart: on-failure:3
redis:
image: redis:latest
command: redis-server
volumes:
- redis:/var/lib/redis
- redis-config:/usr/local/etc/redis/redis.conf
ports:
- 6379:6379
networks:
- redis-network
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
- HTTP_USER=root
- HTTP_PASSWORD=qwerty
ports:
- 8081:8081
networks:
- redis-network
depends_on:
- redis
volumes:
redis:
redis-config:
networks:
redis-network:
driver: bridge

Run the following command to spin up the containers.

docker compose up
2021

Postgres and Redis container services for e2e tests in Github actions

September 8, 2021

End-to-end tests should use a real database connection, and provisioning container service for the Postgres database can be automated using Github actions. The environment variable for the connection string for the newly created database can be set in the step for running e2e tests. The same goes for the Redis instance.

# ...
jobs:
build:
# Container must run in Linux-based operating systems
runs-on: ubuntu-latest
# Image from Docker hub
container: node:20.9.0-alpine3.17
# ...
strategy:
matrix:
# ...
database-name:
- e2e-testing-db
database-user:
- username
database-password:
- password
database-host:
- postgres
database-port:
- 5432
redis-host:
- redis
redis-port:
- 6379
services:
postgres:
image: postgres:latest
env:
POSTGRES_DB: ${{ matrix.database-name }}
POSTGRES_USER: ${{ matrix.database-user }}
POSTGRES_PASSWORD: ${{ matrix.database-password }}
ports:
- ${{ matrix.database-port }}:${{ matrix.database-port }}
# Set health checks to wait until postgres has started
options: --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
# Set health checks to wait until redis has started
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
# ...
- run: npm run test:e2e
env:
DATABASE_URL: postgres://${{ matrix.database-user }}:${{ matrix.database-password }}@${{ matrix.database-host }}:${{ matrix.database-port }}/${{ matrix.database-name }}
REDIS_URL: redis://${{ matrix.redis-host }}:${{ matrix.redis-port }}
# ...
2020