How to use transactions

Use axum-sqlx-tx to automatically commit or rollback SQLx transactions based on handler success.

The all-or-nothing rule

You are building a banking app. A user clicks "Transfer $100". Your code debits Alice's account. The database accepts the change. Then the network stutters, or a bug crashes the credit operation for Bob. Alice has lost $100. Bob has nothing. The money vanished.

This happens because database operations are independent by default. Writing to row A does not know about writing to row B. If one succeeds and the other fails, your data ends up in an inconsistent state. You need a way to group operations so they either all succeed together or all fail together.

That grouping is a transaction. A transaction wraps a set of database commands. You execute them, and then you tell the database to commit. If everything is fine, the database saves all changes at once. If anything goes wrong before the commit, you tell the database to rollback. The database undoes every change in that group, as if you never touched it.

In a web server, managing this manually is tedious. You have to start the transaction, run your queries, catch errors, rollback on failure, commit on success, and return the connection to the pool. One missing rollback call and you leak a connection. One missing commit and your data stays hidden.

The axum-sqlx-tx crate removes this boilerplate. It integrates with Axum's extractor system to start a transaction when the request arrives, pass it to your handler, and automatically commit or rollback based on your handler's result. You write the business logic. The crate handles the lifecycle.

How transactions work under the hood

Think of a transaction like a group photo. Everyone stands in place. The photographer checks the frame. If someone blinks, the photographer says "reset" and everyone goes back to their starting position. You try again. You don't keep the blurry face and the perfect background. You retake the whole shot.

In database terms, the transaction holds a lock on the resources you touch. Other connections can't see your changes until you commit. This isolation prevents race conditions. If two users try to transfer money from the same account at the exact same millisecond, the database serializes the transactions. One runs, commits, and updates the balance. The second sees the new balance and calculates correctly. Without transactions, both might read the old balance, subtract their amounts, and write back, causing one transfer to overwrite the other.

sqlx provides the transaction API. You call pool.begin() to get a transaction object. You run queries on that object instead of the pool. When you call commit(), the changes become permanent. If you call rollback() or drop the transaction without committing, the changes are discarded.

The tricky part in web handlers is the control flow. Handlers return Result types. If a query fails, the ? operator propagates the error up. If you are managing the transaction manually, you have to ensure the error path triggers a rollback. Relying on Drop to rollback works in some cases, but it can be subtle with async runtimes and connection pools. Explicit rollback is safer, but it adds code.

axum-sqlx-tx solves this by wrapping the handler. It uses an Axum extractor to inject the transaction. It inspects the return value. Ok means commit. Err means rollback. You never write commit or rollback in your handler.

Minimal example

Add the crate to your dependencies. You need axum, sqlx, and axum-sqlx-tx. The crate version should match your sqlx feature set.

[dependencies]
axum = "0.7"
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
axum-sqlx-tx = "0.1"
tokio = { version = "1", features = ["full"] }

Set up your router and handler. The handler takes a Transaction extractor. The generic parameter matches your database type, like Sqlite or Postgres.

use axum::{routing::post, Router};
use axum_sqlx_tx::Transaction;
use sqlx::Sqlite;

/// Updates a user's status within a transaction.
/// Commits if the query succeeds, rolls back on error.
async fn update_status(tx: Transaction<Sqlite>) -> Result<(), sqlx::Error> {
    // Execute the update on the transaction object.
    // The ? operator propagates errors, which triggers rollback.
    tx.execute("UPDATE users SET status = 'active' WHERE id = 1")
        .await?;

    // Return Ok to signal success.
    // The extractor commits the transaction automatically.
    Ok(())
}

#[tokio::main]
async fn main() {
    // Create the connection pool.
    let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();

    // Build the router.
    // axum-sqlx-tx usually requires a layer or state configuration
    // to provide the pool to the extractor. Check the crate docs
    // for the exact setup, often involving a TransactionLayer.
    let app = Router::new()
        .route("/update", post(update_status));

    // Run the server.
    println!("Listening on 127.0.0.1:3000");
}

The handler signature is the key. The first argument is Transaction<Sqlite>. Axum calls the extractor to get this value from the request state. The return type is Result<(), sqlx::Error>. The extractor middleware watches this result. If you return Ok, it commits. If you return Err, it rolls back.

Convention aside: Name your extractor argument tx or transaction. This signals to other developers that the value is a transactional scope, not a raw connection or pool. It also matches the mental model of a short-lived scope.

Walkthrough of the lifecycle

When a request hits /update, the framework runs the middleware stack. The axum-sqlx-tx layer begins a transaction on the connection pool. It checks out a connection and starts the transaction block. It passes the transaction object to your handler via the extractor.

Your handler runs. You call tx.execute. The query runs against the transaction. If the query fails, execute returns an Err. The ? operator catches it and returns early from the handler. The middleware sees the Err result. It calls rollback on the transaction. The database discards the changes. The connection returns to the pool. The middleware converts the error into an HTTP response, usually a 500 or a structured error JSON.

If the query succeeds, execute returns Ok. You return Ok(()) from the handler. The middleware sees Ok. It calls commit. The database saves the changes. The connection returns to the pool. The middleware sends a 200 response.

The ? operator is your rollback trigger. Every time you use ? on a sqlx result inside the handler, you are saying "if this fails, abort the whole transaction." This makes error handling declarative. You don't need if let Err blocks or manual rollback calls. The control flow handles it.

Realistic example: multi-step operation

Real handlers do more than one query. You might insert a user, create a profile, and log an audit event. All three must succeed. If the audit log fails, the user and profile should not exist.

use axum::Json;
use axum_sqlx_tx::Transaction;
use serde::Serialize;
use sqlx::{Sqlite, Row};

#[derive(Serialize)]
struct UserResponse {
    id: i64,
    email: String,
}

/// Registers a user with profile and audit log.
/// All operations are grouped in one transaction.
async fn register_user(
    tx: Transaction<Sqlite>,
    Json(payload): Json<RegisterPayload>,
) -> Result<Json<UserResponse>, sqlx::Error> {
    // Insert the user and get the ID.
    // If this fails, the ? rolls back everything.
    let user_id: i64 = sqlx::query_scalar(
        "INSERT INTO users (email) VALUES (?) RETURNING id"
    )
    .bind(&payload.email)
    .fetch_one(&*tx)
    .await?;

    // Insert the profile.
    // Uses the user_id from the previous step.
    tx.execute(
        "INSERT INTO profiles (user_id, bio) VALUES (?, ?)"
    )
    .bind(user_id)
    .bind(&payload.bio)
    .await?;

    // Insert audit log.
    // If this fails, the user and profile are rolled back.
    tx.execute(
        "INSERT INTO audit_logs (action, user_id) VALUES (?, ?)"
    )
    .bind("register")
    .bind(user_id)
    .await?;

    // Return success.
    // The extractor commits the transaction.
    Ok(Json(UserResponse {
        id: user_id,
        email: payload.email,
    }))
}

#[derive(serde::Deserialize)]
struct RegisterPayload {
    email: String,
    bio: String,
}

This handler chains three writes. The user_id flows from the first query to the second and third. If the profile insert fails, the ? returns the error. The middleware rolls back. The user row never persists. The database stays consistent.

Convention aside: Keep transactions short. This handler does three queries and returns. That is fast. If you add network calls, heavy computation, or user input prompts inside the transaction, you hold the database connection for too long. The connection pool has a limited number of connections. If every request holds a connection for seconds, the pool exhausts. New requests get queued or rejected. Do your work, touch the database, and return.

Pitfalls and compiler errors

Transactions are powerful but they have constraints. The first constraint is the return type. Your handler must return a Result. If you return a plain Response or Html<String>, the extractor cannot detect errors. It might commit blindly, or it might panic. The compiler will catch this if the extractor expects a specific trait bound.

If you try to return a non-Result type, you get a trait bound error. The compiler says the type does not implement the required trait. You might see E0277 (trait bound not satisfied). The fix is to wrap your response in Result. Return Ok(Json(...)) or Ok(Html(...)). The error type should match what the middleware expects, usually sqlx::Error or a custom error that implements IntoResponse.

The second pitfall is connection leaks. A transaction holds a connection from the pool. If your handler panics, or if you block the executor, the connection might not return. axum-sqlx-tx handles panics by rolling back, but long-running handlers are a different issue. If you spawn a task and forget to await it, or if you call tokio::time::sleep inside the transaction, you hold the connection while doing nothing. The pool starves.

Always measure transaction duration. Log the time. If a transaction takes longer than a few milliseconds, investigate. Database work should be fast. If you need to do slow work, do it outside the transaction. Read the data, commit, then process.

The third pitfall is the Transaction type itself. It is usually not Clone. You cannot copy it to pass to other functions easily. If you need to refactor your handler into helper functions, you must pass &mut Transaction or restructure the code. The compiler will reject tx.clone() with an error about Clone not being implemented.

If you try to move the transaction into a closure or another task, you get E0382 (use of moved value) or lifetime errors. The transaction is tied to the request scope. Keep the logic in the handler or pass mutable references to synchronous helpers.

Compiler error inline: If you forget to use &*tx or the correct reference when calling sqlx methods that expect a reference, you get E0277 or E0308 (mismatched types). sqlx methods often take &mut or & depending on the version. The axum-sqlx-tx extractor usually dereferences correctly, but check the method signatures.

Decision matrix

Use axum-sqlx-tx when you have a web handler that performs multiple database writes and needs atomicity. Use axum-sqlx-tx when you want to eliminate transaction boilerplate and rely on the framework to commit or rollback based on the handler result. Use axum-sqlx-tx when your error handling follows the standard Result pattern and you want the ? operator to trigger rollbacks automatically.

Use manual sqlx transactions when you need fine-grained control over commit points, such as committing partway through a long operation. Use manual sqlx transactions when you are not using Axum, or when you need nested transactions that the extractor does not support. Use manual sqlx transactions when you need to inspect the transaction state or perform custom rollback logic beyond a simple error propagation.

Use direct sqlx queries without transactions when you only perform a single read or write and do not care about grouping. Use direct queries when performance is critical and the overhead of transaction management is measurable, though this is rare. Use direct queries for simple lookups where consistency across multiple rows is not required.

Use connection pooling crates like deadpool or bb8 when you need a custom pool configuration that sqlx's built-in pool does not provide. Use these when you are integrating with a database driver other than sqlx. Use these when you need advanced pool metrics or eviction policies.

Where to go next

Treat the transaction scope as a lease on a connection. Keep the lease short, return the connection fast, and let the extractor handle the commit.