PostgreSQL FOR PostgreSQL · Conditional · FOR

PL/pgSQL FOR Loop

Master the PL/pgSQL FOR loop — three forms covering integer ranges (with REVERSE and BY step), query result iteration with implicit cursor management, and dynamic queries via FOR ... IN EXECUTE ... USING with safe parameter binding.

PL/pgSQL's FOR loop comes in three flavors, each suited to a different iteration pattern:

FormIterates over
FOR i IN low..high LOOPA range of integers
FOR rec IN SELECT ... LOOPThe rows of a query (implicit cursor)
FOR rec IN EXECUTE sql_text LOOPThe rows of a dynamically-built query

This is the most-used loop construct in PL/pgSQL — both because the syntax is concise and because PostgreSQL handles all the cursor mechanics for you.

[ <<label>> ] FOR loop_counter IN [ REVERSE ] low..high [ BY step ] LOOP statements; END LOOP [ label ];

Three things to know:

  • loop_counter is implicitly declared inside the loop — you don't (and shouldn't) declare it in DECLARE. It exists only inside the loop.
  • REVERSE counts down instead of up. Note: with REVERSE, you write high..low, not low..high.
  • BY step changes the increment — useful for "every other," "every third," etc. Defaults to 1.
⚠️ The original tutorial showed the syntax as FOR LOOP_counter IN ... — capitalizing LOOP as part of the variable name. That's a typo: the keyword is FOR alone, and loop_counter is the variable name (any name will do — it's user-chosen).
Example 1 — Counting 1 to 5
PL/pgSQL — basic FOR
DO $$
BEGIN
    FOR counter IN 1..5 LOOP
        RAISE NOTICE 'counter: %', counter;
    END LOOP;
END
$$;
OUTPUT
psql — 1 to 5
postgres=#DO $$ BEGIN FOR counter IN 1..5 LOOP RAISE NOTICE 'counter: %', counter; END LOOP; END $$;
NOTICE: counter: 1
NOTICE: counter: 2
NOTICE: counter: 3
NOTICE: counter: 4
NOTICE: counter: 5
DO

Both bounds are inclusive — the loop runs for 1, 2, 3, 4, and 5. The variable counter is auto-declared by the FOR statement; it doesn't need to be in DECLARE.

Example 2 — Counting Backward with REVERSE
PL/pgSQL — REVERSE
DO $$
BEGIN
    FOR counter IN REVERSE 5..1 LOOP
        RAISE NOTICE 'counter: %', counter;
    END LOOP;
END
$$;
OUTPUT
psql — REVERSE
postgres=#DO $$ BEGIN FOR counter IN REVERSE 5..1 LOOP RAISE NOTICE 'counter: %', counter; END LOOP; END $$;
NOTICE: counter: 5
NOTICE: counter: 4
NOTICE: counter: 3
NOTICE: counter: 2
NOTICE: counter: 1
DO
📌 With REVERSE, write the bounds high..low — bigger number first. The loop still goes from the first value to the second, but with REVERSE it decrements instead of incrementing. FOR i IN REVERSE 1..5 would not execute the body at all (1 is already smaller than 5 going down).
Example 3 — Custom Step with BY

Iterate from 1 to 10 by twos:

PL/pgSQL — BY step
DO $$
BEGIN
    FOR counter IN 1..10 BY 2 LOOP
        RAISE NOTICE 'counter: %', counter;
    END LOOP;
END
$$;
OUTPUT
psql — BY 2
postgres=#DO $$ BEGIN FOR counter IN 1..10 BY 2 LOOP RAISE NOTICE 'counter: %', counter; END LOOP; END $$;
NOTICE: counter: 1
NOTICE: counter: 3
NOTICE: counter: 5
NOTICE: counter: 7
NOTICE: counter: 9
DO

The most common FOR loop in real PL/pgSQL code: walking through the rows returned by a SELECT.

[ <<label>> ] FOR target IN query LOOP statements; END LOOP [ label ];

target can be a record variable, a %ROWTYPE variable, or a comma-separated list of scalar variables. PostgreSQL implicitly opens a cursor, fetches one row per iteration into target, and closes the cursor when the loop ends.

Example 4 — Walking a Result Set

Setup:

PL/pgSQL — Setup
DROP TABLE IF EXISTS film;

CREATE TABLE film (
    title        VARCHAR(255),
    release_year INTEGER,
    length       INTEGER
);

INSERT INTO film (title, release_year, length) VALUES
    ('Film 1', 1999, 142),
    ('Film 2', 2010, 175),
    ('Film 3', 2008, 152),
    ('Film 4', 2003, 154),
    ('Film 5', 2006, 201),
    ('Film 6', 2009, 195),
    ('Film 7', 2020, 148),
    ('Film 8', 2022, 139),
    ('Film 9', 2015, 142),
    ('Film 10', 2024, 136);
⚠️ The original tutorial had two bugs in this INSERT: INSERT INTO; (extra semicolon between INTO and the table name) and VALUES; (extra semicolon between VALUES and the tuples). Both are syntax errors. Also, all the original titles were misspelled "Flim" — fixed to "Film" above. The corrected version runs cleanly.

Now walk the result set, printing the top 5 longest films:

PL/pgSQL — FOR over query
DO $$
DECLARE
    rec record;
BEGIN
    FOR rec IN
        SELECT title, length
        FROM   film
        ORDER  BY length DESC
        LIMIT  5
    LOOP
        RAISE NOTICE '% (%min)', rec.title, rec.length;
    END LOOP;
END
$$;
OUTPUT
psql — top 5 longest
postgres=#DO $$ DECLARE rec record; BEGIN FOR rec IN SELECT title, length FROM film ORDER BY length DESC LIMIT 5 LOOP RAISE NOTICE '% (%min)', rec.title, rec.length; END LOOP; END $$;
NOTICE: Film 5 (201min)
NOTICE: Film 6 (195min)
NOTICE: Film 2 (175min)
NOTICE: Film 4 (154min)
NOTICE: Film 3 (152min)
DO

When the query string itself is built at runtime — based on input, configuration, or user choice — use FOR ... IN EXECUTE:

[ <<label>> ] FOR target IN EXECUTE query_expression [ USING value [, ...] ] LOOP statements; END LOOP;

USING binds parameters safely — values are sent separately from the SQL text, so they can never be reinterpreted as code. This is your defense against SQL injection when building dynamic queries.

Example 5 — Choosing Sort Order at Runtime
PL/pgSQL — dynamic query
DO $$
DECLARE
    sort_type SMALLINT := 1;     -- 1 = title, 2 = release_year
    rec_count INTEGER  := 5;
    rec       record;
    query     TEXT;
BEGIN
    query := 'SELECT title, release_year FROM film ';

    IF sort_type = 1 THEN
        query := query || 'ORDER BY title';
    ELSIF sort_type = 2 THEN
        query := query || 'ORDER BY release_year';
    ELSE
        RAISE EXCEPTION 'Invalid sort type: %', sort_type;
    END IF;

    query := query || ' LIMIT $1';

    FOR rec IN EXECUTE query USING rec_count LOOP
        RAISE NOTICE '% - %', rec.release_year, rec.title;
    END LOOP;
END
$$;
OUTPUT
psql — dynamic sort
postgres=#DO $$ DECLARE sort_type SMALLINT := 1; rec_count INTEGER := 5; rec record; query TEXT; BEGIN ... FOR rec IN EXECUTE query USING rec_count LOOP ... END $$;
NOTICE: 1999 - Film 1
NOTICE: 2010 - Film 2
NOTICE: 2008 - Film 3
NOTICE: 2003 - Film 4
NOTICE: 2006 - Film 5
DO
💡 The USING clause is non-negotiable for user-supplied values. Never concatenate user input directly into the SQL string — that's classic SQL injection. $1 + USING value sends the value as a parameter; it can hold a quote, a semicolon, anything, and it'll be treated as data, not SQL.

When you're walking rows from one specific table, a %ROWTYPE variable gives you compile-time field-name validation that record defers to runtime:

Example 6 — Updating Stale Orders

Mark orders older than 30 days as Completed. Setup:

PL/pgSQL — Setup
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
    order_id   INTEGER PRIMARY KEY,
    order_date DATE,
    status     VARCHAR(50)
);

INSERT INTO orders (order_id, order_date, status) VALUES
    (1,  '2025-08-01', 'Pending'),
    (2,  '2025-09-15', 'Processing'),
    (3,  '2025-10-10', 'Shipped'),
    (4,  '2025-11-20', 'Pending'),
    (5,  '2025-12-05', 'Processing'),
    (6,  '2026-01-30', 'Shipped'),
    (7,  '2026-02-10', 'Pending'),
    (8,  '2026-03-25', 'Processing'),
    (9,  '2026-04-05', 'Shipped'),
    (10, '2026-04-30', 'Pending');
PL/pgSQL — FOR with %ROWTYPE
DO $$
DECLARE
    order_record orders%ROWTYPE;
BEGIN
    FOR order_record IN
        SELECT * FROM orders
        WHERE  order_date <= current_date - INTERVAL '30 days'
          AND  status <> 'Completed'
    LOOP
        UPDATE orders
        SET    status = 'Completed'
        WHERE  order_id = order_record.order_id;

        RAISE NOTICE 'Order ID %: status updated to Completed', order_record.order_id;
    END LOOP;
END
$$;
⚠️ This loop is instructive but inefficient. The same effect can be achieved with one statement: UPDATE orders SET status = 'Completed' WHERE order_date <= current_date - INTERVAL '30 days' AND status <> 'Completed';. Reach for FOR-row loops only when each row needs application logic the SQL engine can't express — calling functions, format-string building, conditional logging, etc.
  1. Don't declare the loop counter in DECLARE. The FOR statement implicitly declares it; an explicit declaration is redundant or shadowing.
  2. Use FOR rec IN SELECT ... LOOP as the default for walking query results — cleaner than explicit cursors.
  3. Use %ROWTYPE over record when the row shape is fixed — gives you compile-time field validation.
  4. Use EXECUTE ... USING for dynamic queries. Never concatenate user input into the SQL string — that's a SQL injection risk.
  5. With REVERSE, write bounds high..low. The first number is the starting value; REVERSE just changes the direction.
  6. Prefer set-based SQL when possible. A single UPDATE covering many rows is faster than a FOR loop running individual UPDATEs. Loops earn their keep when each row needs application-level logic.
  • FOR has three forms: integer range (FOR i IN 1..5), query result (FOR rec IN SELECT), and dynamic query (FOR rec IN EXECUTE).
  • The loop variable is implicitly declared by the FOR statement — don't put it in DECLARE.
  • Both bounds in a range are inclusive. REVERSE counts down (write high..low). BY step changes the increment.
  • For query iteration, PostgreSQL implicitly manages the cursor — open, fetch, close. You just write the body.
  • Use %ROWTYPE for fixed shapes; use record for flexible shapes.
  • EXECUTE ... USING is the safe pattern for dynamic SQL — values pass as parameters, never as injected text.
  • For uniform updates over many rows, set-based SQL beats any FOR loop.