Rust + PostgreSQL: Building a Backend That Actually Scales — Without the JVM Tax

📅 May 9, 2026
Rust + PostgreSQL: Building a Backend That Actually Scales — Without the JVM Tax
👁 ... views

After writing about what Rust taught me regarding Java’s blind spots, a lot of you asked: “Okay, but what does a real Rust backend look like in production?” Not a toy example. Something that handles traffic, talks to a database, and survives a deploy at 2 AM.

So I built one. Axum for the HTTP layer, sqlx for PostgreSQL, and a connection pool tuned for actual load. I ran the same API surface — user CRUD, full-text search, and a reporting endpoint that joins five tables — that I’ve built a dozen times in Spring Boot. The results surprised me, even after six months of Rust.

Why Rust + PostgreSQL Is a Killer Combo

PostgreSQL has been my go-to database since my pgvector articles and HNSW vs IVFFLAT benchmark. It’s the one database I trust for both relational and vector workloads. Pairing it with Rust isn’t obvious at first — most Rust tutorials use SQLite or a toy ORM. But when you combine sqlx’s compile-time query checking with PostgreSQL’s query planner, you get something Spring Boot’s JPA can’t match: queries that are validated at compile time and optimized at runtime.

With JPA, you write an entity, add a @Query annotation, and pray the generated SQL doesn’t do an N+1. With sqlx, the compiler rejects your code if the query doesn’t match your database schema. I’ve caught three bugs before deployment that would have surfaced as 500 errors in production.

Here’s the setup that works:

[dependencies]
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

The Axum Server — Simple, But Not Trivial

Spring Boot does a lot of magic: auto-configuration, component scanning, embedded Tomcat. I’ve written about what changed in Spring Boot 3.x and I appreciate that ecosystem. But magic has a cost — startup time, memory footprint, and the cognitive overhead of figuring out which auto-configuration kicked in.

Here’s a full Axum server with PostgreSQL connection pooling, CORS, and structured logging:

use axum::{Router, routing::{get, post, put, delete}};
use sqlx::PgPool;
use tower_http::cors::CorsLayer;
use std::net::SocketAddr;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("myapp=info".parse()?)
        )
        .init();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
    
    let pool = PgPool::connect(&database_url).await?;
    sqlx::migrate!("./migrations").run(&pool).await?;

    let app = Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
        .route("/users/search", post(search_users))
        .layer(CorsLayer::permissive())
        .with_state(pool);

    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    tracing::info!("Listening on {}", addr);
    axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
    Ok(())
}

Startup time: 0.12 seconds. Not 0.12 seconds after the JVM warms up. 0.12 seconds from process start to first request accepted.

For comparison, my Spring Boot 3.2 app with the same endpoints takes 4.2 seconds on a cold start and 1.8 seconds with CDS (Class Data Sharing). On a container platform that scales to zero, that 4-second gap is the difference between a fast response and a timeout.

sqlx: Compile-Time Query Validation

This is where sqlx shines. Every query is checked against your actual database schema at compile time. If you rename a column, Rust refuses to build. Here’s a real handler:

use axum::{extract::State, Json};
use sqlx::PgPool;

#[derive(serde::Serialize)]
struct User {
    id: uuid::Uuid,
    username: String,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}

async fn list_users(
    State(pool): State<PgPool>,
) -> Result<Json<Vec<User>>, (http::StatusCode, String)> {
    let users = sqlx::query_as!(
        User,
        r#"
        SELECT id, username, email, created_at
        FROM users
        ORDER BY created_at DESC
        LIMIT 50
        "#
    )
    .fetch_all(&pool)
    .await
    .map_err(|e| (http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(users))
}

The query_as! macro connects to your dev database, reads the schema, and validates that the SELECT columns match the User struct fields. At compile time. If I change email to email_address in the database, this won’t compile. In Spring Boot, this is a runtime DataAccessException that your users discover first.

Full-Text Search That Actually Works

Here’s a search handler that uses PostgreSQL’s tsvector — the same approach I covered in my PostgreSQL tricks article:

async fn search_users(
    State(pool): State<PgPool>,
    Json(query): Json<SearchRequest>,
) -> Result<Json<Vec<User>>, (http::StatusCode, String)> {
    let users = sqlx::query_as!(
        User,
        r#"
        SELECT id, username, email, created_at
        FROM users
        WHERE to_tsvector('english', username || ' ' || email)
              @@ plainto_tsquery('english', $1)
        ORDER BY ts_rank(
            to_tsvector('english', username || ' ' || email),
            plainto_tsquery('english', $1)
        ) DESC
        LIMIT 20
        "#,
        query.term
    )
    .fetch_all(&pool)
    .await
    .map_err(|e| (http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(users))
}

No ORM mapping layer translating your intent into SQL. Just the actual query, validated, with the exact execution plan PostgreSQL will use. If you want to add an index, you write CREATE INDEX — not @Index annotations that may or may not do what you expect.

The Numbers That Matter

I ran both stacks (Spring Boot 3.2 + JPA vs Axum + sqlx) through the same load test: 500 concurrent users, 30-second ramp, mixed CRUD + search traffic. Same PostgreSQL 16 instance, same hardware (4 vCPU, 8GB RAM).

MetricSpring Boot + JPARust + Axum + sqlx
Binary size85 MB (JAR)18 MB (static)
Container memory (idle)340 MB28 MB
Container memory (under load)580 MB42 MB
P50 latency12ms4ms
P99 latency89ms18ms
Startup (cold)4.2s0.12s
Requests/sec (sustained)3,20014,800

The memory difference is the one that keeps me up at night. At 580 MB under load, I can fit 13 Spring Boot containers on a single 8GB node. At 42 MB, I can fit 190. Even accounting for the fact that most services won’t max out at 190, the cost difference for a multi-service architecture is massive.

Now, to be fair: for a typical CRUD API serving 50 requests per second, the JVM overhead is a rounding error. You won’t feel the 340 MB. The difference matters when you’re running 20+ microservices, when you’re paying per-GB for container memory, or when your platform scales to zero and cold starts are user-facing.

What I Got Wrong (So You Don’t Have To)

I won’t pretend this was smooth. Here are the mistakes I made:

Mistake 1: Using query! instead of query_as! for complex joins.

The query! macro returns unnamed tuples. With a five-table join, I ended up with _0, _1, _2… which is unmaintainable. query_as! maps to named structs and is worth the extra typing.

Mistake 2: Not tuning the connection pool.

sqlx::PgPool defaults to a reasonable pool size, but I was burning through connections under load. The fix:

let pool = sqlx::PgPoolOptions::new()
    .max_connections(20)
    .min_connections(5)
    .idle_timeout(std::time::Duration::from_secs(600))
    .max_lifetime(std::time::Duration::from_secs(1800))
    .connect(&database_url)
    .await?;

For a service handling 500 concurrent users, 20 max connections is plenty — PostgreSQL handles connection multiplexing well. Going above 50 without PgBouncer starts hurting the database more than helping the application.

Mistake 3: Ignoring EXPLAIN ANALYZE because “the ORM handles it.”

Habit from JPA days. In Rust, you are the ORM. I ran EXPLAIN ANALYZE on my search query and found it was doing a sequential scan on 200K rows. Adding a GIN index on the tsvector column dropped query time from 45ms to 2ms. Same lesson from my HNSW vs IVFFLAT benchmark — always verify the execution plan, never assume.

The Objections I Expect

ConcernReality
”Rust compilation is slow”True for cold builds (~90s for a medium project). But incremental compilation is 3-5 seconds. And catching query mismatches at compile time saves hours of runtime debugging.
”The ecosystem is smaller than Java’s”Absolutely. No Spring equivalent. But Tokio + Axum + sqlx cover 90% of backend needs. For the other 10% — message queues, gRPC, OAuth — mature crates exist.
”What about GraalVM native-image?”Fair point. GraalVM brings Java startup to ~0.1s and memory to ~50MB. But GraalVM has reflection limitations, longer build times, and not all libraries are compatible. Rust’s advantages (memory safety, zero-cost abstractions) exist at the language level, not through a post-compilation tool.
”We have too much Java code to rewrite”Don’t rewrite. Write new services in Rust. The sidecar pattern works: keep existing Java services, build new ones in Rust, let them share the same PostgreSQL instance.
”sqlx isn’t an ORM — no change tracking, no migrations”True. sqlx deliberately avoids ORM features. Migrations are handled by sqlx migrate CLI — simple SQL files, versioned, reversible. For change tracking, you write explicit UPDATE statements. I consider this a feature, not a limitation.

When I’d Still Reach for Spring Boot

I’m not dogmatic about this. Spring Boot is still my choice when:

  • The team knows Java but not Rust. Training cost matters more than marginal performance gains.
  • You need the full Spring ecosystem. Spring Security, Spring Batch, Spring Integration — these are mature, battle-tested, and hard to replace.
  • Your application is CRUD-heavy with complex business logic. Spring Data + JPA’s declarative approach is genuinely productive for simple data access.

But for services where latency matters, where memory efficiency affects your cloud bill, or where you’re building something new and want compile-time guarantees around your database queries — Rust + PostgreSQL is the stack I’m reaching for first.

The Bottom Line

Rust + PostgreSQL isn’t about replacing Java. It’s about having a second tool that excels where Java has structural limitations: startup time, memory footprint, and the compile-time/runtime gap for database queries.

I’ve shipped both stacks to production. The Spring Boot app works fine — it’s been running for three years without a memory-related incident. But the Rust service costs less to run, deploys faster, and I sleep better knowing the compiler has already validated every SQL query I wrote.

If you’re a Java developer curious about Rust, this is the combination I’d start with. Axum is approachable, sqlx gives you the database safety net you’re used to, and PostgreSQL means you don’t need to learn a new database ecosystem.

What backend stack are you reaching for in 2026? I’d love to hear if you’ve shipped Rust + PostgreSQL to production — or if you tried and hit a wall I haven’t mentioned yet.


💡

Enjoying the content? Here are tools I personally use and recommend:

  • 🌐 Hosting: Bluehost — what this blog runs on
  • 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use

Purchases through my links help keep this blog ad-free 💙

Enjoyed this post?

Subscribe to the newsletter or follow on YouTube for more dev content.

🎬 Watch Shorts