## 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:
“`text
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 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)
1. 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:
“`text
[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:
“`text
[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](https://sourcecode.confdroid.com/confdroid/confdroid_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).
2. **Alternative if you can’t touch pool_mode**: Force-disable prepared statements in Gitea
Append `?prepare_threshold=0` to your connection string.
Unfortunately, Gitea builds the DSN from separate fields (`HOST`, `NAME`, `SSL_MODE`), so the trick is:
“`ini
[database]
DB_TYPE = postgres
HOST =
NAME =
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.
3. **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, `SET` commands, 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.
[](https://www.buymeacoffee.com/grizzly_coda)
[](https://hetzner.cloud/?ref=EY14C8Tema9j)
[](https://confdroid.substack.com/subscribe?params=%5Bobject%20Object%5D)
[](https://feedback.confdroid.com/)
## Related posts
– [Databases – Postgresql – Pilot](https://confdroid.com/db-postgres-pilot/)
– [Databases – Postgresql – PGbouncer](https://confdroid.com/db-postgres-pgbouncer/)


