Skip to content

Migration runner: psycopg2 %-substitution trips on PL/pgSQL function bodies (misleading 'list index out of range') #40

@justin-k-bruce

Description

@justin-k-bruce

Symptom

A migration containing a PL/pgSQL function with % characters in a RAISE EXCEPTION (or format()) call fails with:

RuntimeError: Migration failed: list index out of range

The error message gives no hint that the % chars are the problem. The user-facing failure looks like a tina4 internal bug, when it's really psycopg2's argument-substitution system tripping on the literal percent signs.

Root cause

postgres.PostgresAdapter.execute(sql, params) calls

cursor.execute(sql, params or [])

psycopg2 interprets % as parameter placeholders always when a params arg is passed — even an empty list []. So a function body like

CREATE OR REPLACE FUNCTION foo() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF FOUND THEN
        RAISE EXCEPTION 'thing % conflicts with %', a, b;
    END IF;
    RETURN NEW;
END $$

…is sent to psycopg2 as (sql, []). psycopg2 tries to substitute the two % chars, finds zero values in [], and raises IndexError: list index out of range from inside its own format machinery. tina4's Database.execute catches the exception, stores str(e) in last_error, and returns False. The migration runner then surfaces

RuntimeError: Migration failed: list index out of range

…with no file location or hint about the actual cause.

Repro

# repro.py
from tina4_python.database import Database

db = Database("postgresql://user:pw@localhost/db")
sql = '''
CREATE OR REPLACE FUNCTION repro_fn() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    RAISE EXCEPTION 'a % b';
    RETURN NEW;
END $$;
'''
print(db.execute(sql))   # False
print(db.last_error)      # list index out of range

Same fires when the function uses format('… %I …', x) — any % literal triggers it.

Workaround

Double-up every % inside $$…$$ bodies → %%. The user has to know this gotcha exists; the error message doesn't suggest it.

Suggested fix

When params is empty/None, don't pass a params arg to cursor.execute at all — psycopg2 only does %-substitution when given the second arg:

# postgres.py PostgresAdapter.execute
if params:
    cursor.execute(sql, params)
else:
    cursor.execute(sql)

This is fully backwards-compatible and lets PL/pgSQL function bodies keep their literal % chars without surprising the author.

If you'd prefer to keep the unconditional 2-arg call, at minimum a clearer error message would help — wrap the IndexError with something like "SQL contains literal % chars that psycopg2 read as parameter placeholders — escape them as %% inside function bodies".

Happy to send a PR if the suggested fix above looks reasonable.

Hit on tina4-python 3.12.1 + psycopg2-binary 2.9.x against PG 16. Same code shape ran on the old _t4_lastval_probe-less version too, so it's not new.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions