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.

Jose Celano - 01/05/2026
PostgreSQL Support in Torrust Tracker

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:

  1. 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).
  2. 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.
  3. Persistence benchmarking — A benchmark runner (persistence_benchmark_runner) that measures per-operation latency for each driver using --driver, --db-version, and --ops flags, and outputs a JSON report for easy diffing across runs.
  4. Split persistence traits — The monolithic Database trait 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.
  5. Migrate SQLite and MySQL to sqlx — Both existing drivers were rewritten using async sqlx connection pools, replacing the old synchronous r2d2 / rusqlite / mysql stack.
  6. 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.
  7. Align Rust and DB types — The MySQL download-counter columns were widened from INTEGER (signed 32-bit, max ~2.1 billion) to BIGINT via a versioned migration. The Rust type NumberOfDownloads stays u32 — 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::PostgreSQL variant in the configuration crate (serialises as "postgresql").
  • Four PostgreSQL migration files, timestamp-aligned with the existing SQLite/MySQL history.
  • A new postgres.rs driver implementing all four narrow traits via async sqlx.
  • 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 sqlx migration 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:

bash
# 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 16

The output is plain JSON so you can redirect it to a file, diff runs, or feed it into any visualisation tool:

bash
cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \
  --driver sqlite3 > .benchmarks/bench-results-sqlite3.json

A sample report looks like this:

json
{
  "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.

Detailed documentation for all three tools is available in the Torrust Tracker repository.

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

DriverTotal (ms)
SQLite3119 ms
MySQL 8.46 372 ms
MySQL 8.07 272 ms
PostgreSQL 171 451 ms

Per-operation medians (µs)

OperationSQLite3MySQL 8.4MySQL 8.0PostgreSQL 17
save_torrent_downloads89769984298
load_torrent_downloads2311211588
load_all_torrents_downloads77172171146
increase_downloads_for_torrent707731 005302
save_global_downloads767931 066299
load_global_downloads2111513786
increase_global_downloads677741 036305
add_info_hash_to_whitelist81735981294
get_info_hash_from_whitelist2110911895
load_whitelist55161175135
remove_info_hash_from_whitelist81766962293
add_key_to_keys81750974292
get_key_from_keys2211812995
load_keys77167189155
remove_key_from_keys73739994300

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:

toml
[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