PostgreSQL Support in Torrust Tracker
Torrust Tracker now supports PostgreSQL as a first-class database backend. Learn about the journey from a community feature request to a full persistence overhaul, the new tools built along the way, and what this means for the upcoming major release.
Introduction
We are excited to announce that Torrust Tracker now supports PostgreSQL as a first-class database backend, alongside the existing SQLite and MySQL drivers. This has been a long-standing feature request, and it is the result of a long journey that involved a full overhaul of the persistence layer, new tooling, and valuable contributions from the community.
In this post we will walk through the history of this feature, explain what changed under the hood, highlight the new tooling built along the way, and share our plans for the upcoming major release.
A Long-Requested Feature
The first request for PostgreSQL support was filed back on September 20, 2023 in issue #462. PostgreSQL is widely used in production environments and it was a natural fit for operators who already run PostgreSQL infrastructure and want to avoid introducing a second database engine.
At the time, however, adding PostgreSQL support was not straightforward. The tracker's
persistence layer was synchronous, using the r2d2 connection pool crate with rusqlite and mysql. The problem is that the postgres crate wraps tokio_postgres, which tries to spawn a nested Tokio runtime —
something that conflicts with the tracker's existing async runtime. The community PR #1684 worked around this by spawning a fresh OS thread per database operation, but that approach
was rejected as a performance concern: under load, each query would pay the cost of OS thread
creation and context switching.
The real fix was to migrate all drivers to an async-native library. Rather than bolt
PostgreSQL on top of the synchronous stack with a workaround, we decided to do the right
thing first: replace r2d2 / rusqlite / mysql with
async sqlx across all drivers, and then add PostgreSQL on top of that clean foundation.
The Persistence Overhaul EPIC
In May 2025 we opened EPIC #1525 — Overhaul persistence, which became the umbrella for all the work needed before PostgreSQL could be added cleanly. The driving insight was the Martin Fowler quote we kept returning to:
"Make the change easy, then make the easy change."
The EPIC was broken down into two phases and nine sequential sub-issues, each
independently reviewable and mergeable into develop.
Phase 1 — Make the Change Easy
Before touching a single PostgreSQL file, we had to modernise the persistence stack:
- Persistence test coverage — A compatibility-matrix CI workflow that tests the tracker against a range of supported SQLite, MySQL, and PostgreSQL versions (see the DB Compatibility Matrix section below).
- qBittorrent end-to-end runner — A Rust binary that runs a full seeder → tracker → leecher download using real, containerised qBittorrent clients, so we can verify correct behaviour at the protocol level.
- Persistence benchmarking — A benchmark runner (
persistence_benchmark_runner) that measures per-operation latency for each driver using--driver,--db-version, and--opsflags, and outputs a JSON report for easy diffing across runs. - Split persistence traits — The monolithic
Databasetrait was split into four narrow context traits (SchemaMigrator,TorrentMetricsStore,WhitelistStore,AuthKeyStore) plus a blanket aggregate supertrait, reducing coupling and making each driver easier to implement and test in isolation. - Migrate SQLite and MySQL to sqlx — Both existing drivers were rewritten
using async
sqlxconnection pools, replacing the old synchronousr2d2/rusqlite/mysqlstack. - Introduce schema migrations — Raw DDL was replaced with
sqlx::migrate!(), and a legacy-bootstrap path was added to history-align databases that were created before migrations existed. - Align Rust and DB types — The MySQL download-counter columns were
widened from
INTEGER(signed 32-bit, max ~2.1 billion) toBIGINTvia a versioned migration. The Rust typeNumberOfDownloadsstaysu32— the wider column is intentional; the application type bounds writes at compile time.
Phase 2 — Make the Easy Change
With the foundation in place, adding PostgreSQL (sub-issue 1525-08, tracked in issue #1723) became straightforward:
- A new
Driver::PostgreSQLvariant in the configuration crate (serialises as"postgresql"). - Four PostgreSQL migration files, timestamp-aligned with the existing SQLite/MySQL history.
- A new
postgres.rsdriver implementing all four narrow traits via asyncsqlx. - Wiring in the driver factory and setup dispatch.
- Testcontainers-based driver tests and environment-gated execution.
- The compatibility-matrix, qBittorrent E2E runner, and benchmark runner all extended for PostgreSQL.
- Default PostgreSQL container config and updated documentation.
A Community-Driven Feature
A key part of this story is the contribution from community member DamnCrab. They opened PR #1684 with an initial PostgreSQL implementation using r2d2_postgres, which started the conversation about the right approach. Their follow-up PR #1695 incorporated review feedback, and reviewer guidance was provided in PR #1700 to help bridge the gap.
Three ideas from DamnCrab's work were retained in the final implementation:
- The DB compatibility matrix script to validate tracker compatibility across database versions.
- End-to-end tests using a real BitTorrent client (containerised qBittorrent).
- Basic database benchmarking to compare persistence performance before/after the
sqlxmigration and across database engines.
Thank you, DamnCrab, for the contributions and ideas that made their way into the final result.
New Tooling
The overhaul produced several tools that are useful beyond just adding PostgreSQL support:
DB Compatibility Matrix
A GitHub Actions workflow (db-compatibility.yaml) that runs the tracker's full driver test suite against a matrix of database versions on every
push and pull request:
- MySQL: 8.0, 8.4
- PostgreSQL: 14, 15, 16, 17
Each combination spins up a real container via testcontainers and runs the driver tests with the db-compatibility-tests feature flag. This explicitly
documents which database versions the tracker is compatible with, and protects users who run
infrastructure with a version that differs from what the developers typically test against.
If a new database release introduces a breaking change, the matrix catches it before it reaches
users.
qBittorrent End-to-End Runner
A Rust binary that runs a complete BitTorrent transfer — seeder announces, leecher connects, download completes — using real qBittorrent clients running in containers. This is the closest we can get to a real-world integration test without deploying to production.
Benchmark Runner
The persistence_benchmark_runner is a developer binary that measures the persistence-layer operations implemented by the Database trait. It benchmarks one driver per invocation and prints a JSON
report to standard output with per-operation timing statistics: count, best, median, and worst in microseconds.
Run it with:
# SQLite
cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \
--driver sqlite3
# MySQL
cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \
--driver mysql --db-version 8.4
# PostgreSQL
cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \
--driver postgresql --db-version 16The output is plain JSON so you can redirect it to a file, diff runs, or feed it into any visualisation tool:
cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \
--driver sqlite3 > .benchmarks/bench-results-sqlite3.jsonA sample report looks like this:
{
"meta": {
"git_revision": "16c9c8a4...",
"driver": "sqlite3",
"db_version": "-",
"ops": 100,
"timestamp": "2026-04-28T16:23:24Z"
},
"operations": [
{
"name": "save_torrent_downloads",
"count": 100,
"best_us": 66,
"median_us": 70,
"worst_us": 79
}
]
}It is intentionally simple — there is no built-in comparison mode. The value comes from running it before and after a change (or across drivers) and diffing the JSON files. This makes it easy to catch performance regressions early, without the overhead of a full criterion benchmark suite.
There is also a GitHub Actions workflow (db-benchmarking.yaml) that runs the benchmark against all three drivers on every push and pull request. It runs with --ops 10 — just enough to confirm the binary builds and executes cleanly — rather
than producing statistically significant numbers. The main purpose in CI is to catch compilation
breakages and driver-level errors early, not to track performance over time.
Benchmark Results
With all three drivers in place we ran the benchmark runner for the first time across all
engines on the same machine (AMD Ryzen 9 7950X, Ubuntu 25.10, Docker 28.3.3) with --ops 100. The full report is available in the repository at docs/benchmarking/runs/2026-05-01/REPORT.md.
Total benchmark time
| Driver | Total (ms) |
|---|---|
| SQLite3 | 119 ms |
| MySQL 8.4 | 6 372 ms |
| MySQL 8.0 | 7 272 ms |
| PostgreSQL 17 | 1 451 ms |
Per-operation medians (µs)
| Operation | SQLite3 | MySQL 8.4 | MySQL 8.0 | PostgreSQL 17 |
|---|---|---|---|---|
| save_torrent_downloads | 89 | 769 | 984 | 298 |
| load_torrent_downloads | 23 | 112 | 115 | 88 |
| load_all_torrents_downloads | 77 | 172 | 171 | 146 |
| increase_downloads_for_torrent | 70 | 773 | 1 005 | 302 |
| save_global_downloads | 76 | 793 | 1 066 | 299 |
| load_global_downloads | 21 | 115 | 137 | 86 |
| increase_global_downloads | 67 | 774 | 1 036 | 305 |
| add_info_hash_to_whitelist | 81 | 735 | 981 | 294 |
| get_info_hash_from_whitelist | 21 | 109 | 118 | 95 |
| load_whitelist | 55 | 161 | 175 | 135 |
| remove_info_hash_from_whitelist | 81 | 766 | 962 | 293 |
| add_key_to_keys | 81 | 750 | 974 | 292 |
| get_key_from_keys | 22 | 118 | 129 | 95 |
| load_keys | 77 | 167 | 189 | 155 |
| remove_key_from_keys | 73 | 739 | 994 | 300 |
Takeaways
- SQLite3 is the fastest for single-node, embedded use cases — no network round-trip, everything in-process. It is best suited for development, testing, or single-server deployments where concurrent write load is low.
- PostgreSQL 17 comfortably beats both MySQL versions for write operations. Write medians (~290–305 µs) are roughly 2.5–3× faster than MySQL 8.0 and ~60% faster than MySQL 8.4.
- Read performance is comparable between PostgreSQL 17 and MySQL 8.4 for
simple lookups; aggregate reads (
load_*) are slightly slower on PostgreSQL. - Overall PostgreSQL is significantly faster than MySQL in total benchmark time (1 451 ms vs 6 372 ms for MySQL 8.4), driven primarily by faster write operations.
These numbers are the first PostgreSQL baseline. Future runs will track regressions as the persistence layer continues to evolve, and will provide a growing picture of how the drivers compare over time.
What's Next
PostgreSQL support is already merged into the develop branch and will be
included in the next major release (v4.0.0), which we are planning to
ship this year — though no date is set yet. If you want to try it out today, you can build
from develop and set the driver in your tracker configuration:
[core.database]
driver = "postgresql"
path = "postgresql://USER:PASSWORD@localhost:5432/DBNAME"A default Docker Compose configuration for PostgreSQL is also included in the repository, so you can spin up a local instance with a single command.
Resources & External Links
- torrust-tracker#462 — Original PostgreSQL feature request (Sep 2023)
- torrust-tracker#1525 — EPIC: Overhaul persistence
- torrust-tracker#1723 — Sub-issue 1525-08: Add PostgreSQL driver
- torrust-tracker PR#1684 — Community PR: Add PostgreSQL database driver (DamnCrab)
- torrust-tracker PR#1695 — Community follow-up PR (DamnCrab)
- torrust-tracker PR#1700 — Reviewer guidance and additional changes
- torrust-tracker PR#1725 — Close EPIC #1525: persistence overhaul
- Benchmark report 2026-05-01 — first run including PostgreSQL 17 baseline
- persistence_benchmark_runner.rs — source code of the benchmark runner binary
- db-benchmarking.yaml — GitHub Actions workflow running benchmarks on every push/PR
- db-compatibility.yaml — GitHub Actions workflow testing MySQL and PostgreSQL version compatibility
- sqlx — Async, pure-Rust SQL toolkit
- Rust, PostgreSQL, and sqlx — article by @skerkour