Fixing Gitea 500 Errors with PostgreSQL + pgBouncer: The Prepared Statements Trap
If you’ve ever stared at random 500 Internal Server Errors in Gitea — especially on repo views, commit pages, PR lists, or pretty much any action — and your logs are full of cryptic PostgreSQL driver errors like these:
pq: bind message supplies 3 parameters, but prepared statement "" requires 18
pq: invalid input syntax for type bigint: "confdroid"
pq: bind message supplies 1 parameters, but prepared statement "" requires 2
Repo.GetTopLanguageStats, pq: bind message has 7 result formats but query has 6 columns
…and you’re running Gitea (in my case 1.25.4) behind pgBouncer on port 6432 (the classic tell), then congratulations: you’ve hit one of the most frustrating compatibility gotchas in the Postgres ecosystem.
The Symptoms
- Errors happen intermittently but frequently across almost all UI actions.
- Debug/Trace logging in Gitea shows nothing helpful at first — just stack traces pointing to XORM queries.
- Only Switching to RUN_MODE = dev in app.ini finally reveals the real database errors.
- The issue persists even after migrations, schema checks, or fresh databases.
- No obvious resource pressure, connection limits, or timeouts in HAProxy/Kubernetes.
Root Cause: pgBouncer Transaction Pooling + Server-Side Prepared Statements
Gitea uses XORM (which relies on the Go lib/pq driver) and server-side prepared statements by default for performance. These statements are prepared once per session and reused.
pgBouncer’s default transaction pooling mode (pool_mode = transaction) reuses the same backend Postgres connection pgBouncer’s default transaction pooling mode (pool_mode = transaction) reuses the same backend Postgres connection only for the duration of a single transaction. Once the transaction commits/rolls back, the connection goes back to the pool and can be handed to a completely different client.. Once the transaction commits/rolls back, the connection goes back to the pool and can be handed to a completely different client.
Prepared statements, however, live at the session level — not transaction level. So when pgBouncer gives your Gitea connection a new backend, the previously prepared statement is gone from that backend. The driver tries to bind/execute it anyway → mismatch in parameter counts, column formats, or even type coercion failures (like trying to parse a username string as a bigint ID).
Classic symptoms:
- “bind message supplies X parameters, but prepared statement requires Y”
- “prepared statement does not exist”
- Random type errors on perfectly valid data
Solutions Ranked (What Finally Worked for Me)
- Best long-term fix: Switch the affected database to session pooling. pgBouncer allows per-port or per-user overrides. Since my other apps prefer transaction mode for maximum connection efficiency, I added this to my pgbouncer.ini:
[users]
my_gitea_db_user = pool_mode=session
If you have a number of different connection with different pooling session to manage, you can also configure it per port:
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = session
[pgbouncer_tx]
listen_addr = 0.0.0.0
listen_port = 6433
pool_mode = transaction
[pgbouncer_stmt]
listen_addr = 0.0.0.0
listen_port = 6434
pool_mode = statement
Reload pgBouncer (RELOAD; via admin console or `pgbouncer -R?). Since I am using my own container solution for pgbouncer, I redeployed it to Kubernetes, which restarts it.
Result: Prepared statements stay alive for the full Gitea session → no more stale/missing statements. Zero changes needed in Gitea config. Performance impact is minimal for a typical Forge instance (fewer connections overall than you’d think).
- Alternative if you can’t touch pool_mode: Force-disable prepared statements in Gitea
Append
?prepare_threshold=0to your connection string. Unfortunately, Gitea builds the DSN from separate fields (HOST,NAME,SSL_MODE), so the trick is:
[database]
DB_TYPE = postgres
HOST =
<host>:6432
NAME =
<gitea_database>?prepare_threshold=0&sslmode=require
USER = ...
PASSWD = ...
This tells lib/pq to skip server-side prepare (falling back to simple query + literal params).
But — pgBouncer rejects unknown startup parameters like prepare_threshold → “unsupported startup parameter” error.
→ Only works if you bypass pgBouncer or use direct Postgres connections.
- Nuclear option: Switch to SQLite
If your instance is small/medium and you don’t need Postgres features → just flip to SQLite in app.ini. No pooler, no prepared statement drama. Migration via gitea admin migrate is straightforward. Many production forges run happily on SQLite.
Key Takeaways
- Transaction pooling is great for connection scaling but breaks apps relying on session-scoped features (prepared statements,
SETcommands, temp tables, advisory locks). - Session pooling is safer for ORMs/drivers that prepare statements automatically (Gitea, many Go/Rails/Java apps).
- pgBouncer 1.21+ added experimental prepared statement support in transaction mode (
max_prepared_statements > 0), but it’s still opt-in and has caveats — test thoroughly if you go that route. - Always check your pooler port (6432 = pgBouncer default) when seeing weird bind/prepare errors in lib/pq or similar drivers.
After changing to pool_mode=session for the Gitea user, all 500s vanished instantly. Pages load, commits render, language stats work — rock solid.
Hope this saves someone else hours (or days) of head-scratching. If you’re running Gitea + Postgres + pgBouncer, double-check that pool mode!
Happy forging!
Did you find this post helpful? You can support me.


Related posts
Author Profile
Latest entries
blog13.03.2026Databases – Postgresql – Gitea and PGBouncer
blog27.02.2026ConfDroid Puppet Modules – Gitea
blog26.02.2026ConfDroid Puppet Modules – Postgresql
blog23.02.2026Secure & Smart – Pilot




