Posted On 13.03.2026

Databases – Postgresql – Gitea and PGBouncer

0 comments
confdroid.com >> blog >> Databases – Postgresql – Gitea and PGBouncer

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 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:
[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.

Advertisements

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).

  1. 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:
[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.

  1. 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.

"Buy Me A Coffee"

Substack

ConfDroid Feedback Portal

Related posts

Author Profile

12ww1160DevOps engineer & architect

Advertisements

Leave a Reply

Your email address will not be published. Required fields are marked *

9 − one =

Related Post

converting openSSL x509 into Kubernetes secrets

Serving an application in Kubernetes via https requires an openssl key and secret, so you…

How to add an existing Paypal button to WordPress

So you run a Wordpress website and want to add a simple button for donations…

Centralized OIDC for Wiki.js using Keycloak

Single Sign-On for Wiki.js with Keycloak Managing multiple credentials across internal tools is tedious. We’ve…