SQLx Rust: Banco de Dados Async Compile-Time | Rust Brasil

Guia completo do SQLx em Rust: queries verificadas em compile-time, connection pooling, migrations, PostgreSQL, MySQL e SQLite.

Introdução

O SQLx é uma biblioteca de acesso a banco de dados assíncrona e pura Rust que se destaca por uma funcionalidade única: verificação de queries SQL em tempo de compilação. Isso significa que erros de SQL – colunas inexistentes, tipos incompatíveis, tabelas erradas – são detectados pelo compilador antes de executar o código.

Diferente de ORMs tradicionais como Diesel, o SQLx não abstrai o SQL. Você escreve SQL puro e o SQLx verifica a correção e mapeia os resultados para structs Rust automaticamente. Essa abordagem combina a flexibilidade do SQL puro com a segurança de tipos do Rust.

Por que usar o SQLx?

  • Queries verificadas em compile-time: erros de SQL detectados antes de executar
  • SQL puro: sem DSL ou linguagem intermediária
  • 100% assíncrono: construído sobre Tokio, async-std ou actix
  • Connection pooling: pool de conexões embutido e configurável
  • Migrations: sistema de migrations integrado
  • Multi-banco: PostgreSQL, MySQL, MariaDB, SQLite
  • Streaming: resultados como streams assíncronos
  • FromRow: mapeamento automático de linhas para structs

Instalação

Adicione o SQLx ao seu Cargo.toml:

[dependencies]
sqlx = { version = "0.8", features = [
    "runtime-tokio",  # Runtime assíncrono
    "tls-rustls",     # TLS via rustls
    "postgres",       # Suporte a PostgreSQL
    "chrono",         # Tipos de data/hora
    "uuid",           # Tipo UUID
    "json",           # Tipo JSON/JSONB
] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }

Para usar queries verificadas em compile-time, você também precisa da CLI:

cargo install sqlx-cli

Features disponíveis por banco de dados:

# PostgreSQL
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres"] }

# MySQL/MariaDB
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "mysql"] }

# SQLite
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "sqlite"] }

Uso Básico

Conectando ao banco de dados

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    // Criar pool de conexões
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .acquire_timeout(Duration::from_secs(3))
        .connect("postgres://usuario:senha@localhost:5432/meu_banco")
        .await?;

    // Ou usar variável de ambiente DATABASE_URL
    // let pool = PgPoolOptions::new()
    //     .connect(&std::env::var("DATABASE_URL").unwrap())
    //     .await?;

    println!("Conectado ao PostgreSQL!");

    // Teste de conexão
    let resultado: (i64,) = sqlx::query_as("SELECT $1")
        .bind(150_i64)
        .fetch_one(&pool)
        .await?;

    println!("Resultado: {}", resultado.0);

    Ok(())
}

Query simples com query!()

A macro query!() verifica a query SQL em tempo de compilação:

use sqlx::PgPool;

async fn exemplo_query(pool: &PgPool) -> Result<(), sqlx::Error> {
    // query!() verifica a query em compile-time
    // Requer DATABASE_URL configurado no ambiente ou em .env
    let registro = sqlx::query!(
        "SELECT id, nome, email FROM usuarios WHERE id = $1",
        1_i32
    )
    .fetch_one(pool)
    .await?;

    // Os campos são tipados automaticamente!
    println!("ID: {}", registro.id);           // i32
    println!("Nome: {}", registro.nome);       // String
    println!("Email: {}", registro.email);     // String

    Ok(())
}

Mapeamento para structs com FromRow

use sqlx::{FromRow, PgPool};

#[derive(Debug, FromRow)]
struct Usuario {
    id: i32,
    nome: String,
    email: String,
    ativo: bool,
    criado_em: chrono::NaiveDateTime,
}

async fn listar_usuarios(pool: &PgPool) -> Result<Vec<Usuario>, sqlx::Error> {
    // query_as mapeia automaticamente para a struct
    let usuarios = sqlx::query_as::<_, Usuario>(
        "SELECT id, nome, email, ativo, criado_em FROM usuarios ORDER BY nome"
    )
    .fetch_all(pool)
    .await?;

    Ok(usuarios)
}

// Com query_as!() para verificação em compile-time
async fn obter_usuario(pool: &PgPool, id: i32) -> Result<Option<Usuario>, sqlx::Error> {
    let usuario = sqlx::query_as!(
        Usuario,
        "SELECT id, nome, email, ativo, criado_em FROM usuarios WHERE id = $1",
        id
    )
    .fetch_optional(pool)
    .await?;

    Ok(usuario)
}

Inserção, atualização e exclusão

use sqlx::PgPool;

#[derive(Debug)]
struct NovoUsuario {
    nome: String,
    email: String,
}

async fn criar_usuario(pool: &PgPool, novo: &NovoUsuario) -> Result<i32, sqlx::Error> {
    let registro = sqlx::query!(
        r#"
        INSERT INTO usuarios (nome, email, ativo, criado_em)
        VALUES ($1, $2, true, NOW())
        RETURNING id
        "#,
        novo.nome,
        novo.email,
    )
    .fetch_one(pool)
    .await?;

    Ok(registro.id)
}

async fn atualizar_usuario(
    pool: &PgPool,
    id: i32,
    nome: &str,
    email: &str,
) -> Result<bool, sqlx::Error> {
    let resultado = sqlx::query!(
        "UPDATE usuarios SET nome = $1, email = $2 WHERE id = $3",
        nome,
        email,
        id,
    )
    .execute(pool)
    .await?;

    Ok(resultado.rows_affected() > 0)
}

async fn deletar_usuario(pool: &PgPool, id: i32) -> Result<bool, sqlx::Error> {
    let resultado = sqlx::query!(
        "DELETE FROM usuarios WHERE id = $1",
        id,
    )
    .execute(pool)
    .await?;

    Ok(resultado.rows_affected() > 0)
}

Transações

use sqlx::PgPool;

async fn transferir_saldo(
    pool: &PgPool,
    de_conta: i32,
    para_conta: i32,
    valor: f64,
) -> Result<(), sqlx::Error> {
    // Iniciar transação
    let mut tx = pool.begin().await?;

    // Debitar da conta de origem
    let resultado = sqlx::query!(
        "UPDATE contas SET saldo = saldo - $1 WHERE id = $2 AND saldo >= $1",
        valor,
        de_conta,
    )
    .execute(&mut *tx)
    .await?;

    if resultado.rows_affected() == 0 {
        // Rollback automático quando tx é dropado sem commit
        return Err(sqlx::Error::RowNotFound);
    }

    // Creditar na conta de destino
    sqlx::query!(
        "UPDATE contas SET saldo = saldo + $1 WHERE id = $2",
        valor,
        para_conta,
    )
    .execute(&mut *tx)
    .await?;

    // Registrar a transferência
    sqlx::query!(
        r#"
        INSERT INTO transferencias (de_conta, para_conta, valor, data)
        VALUES ($1, $2, $3, NOW())
        "#,
        de_conta,
        para_conta,
        valor,
    )
    .execute(&mut *tx)
    .await?;

    // Commit da transação
    tx.commit().await?;

    println!(
        "Transferência de R${:.2} da conta {} para {} concluída",
        valor, de_conta, para_conta
    );

    Ok(())
}

Recursos Avançados

Migrations

O SQLx possui um sistema de migrations integrado:

# Criar pasta de migrations
sqlx migrate add criar_tabela_usuarios

# Isso cria: migrations/20260227120000_criar_tabela_usuarios.sql

Escreva a migration SQL:

-- migrations/20260227120000_criar_tabela_usuarios.sql

CREATE TABLE usuarios (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    senha_hash VARCHAR(255) NOT NULL,
    ativo BOOLEAN NOT NULL DEFAULT true,
    criado_em TIMESTAMP NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_usuarios_email ON usuarios(email);
CREATE INDEX idx_usuarios_ativo ON usuarios(ativo);

Execute as migrations:

# Executar migrations pendentes
sqlx migrate run

# Reverter última migration
sqlx migrate revert

# Verificar status
sqlx migrate info

Executar migrations no código:

use sqlx::PgPool;
use sqlx::migrate::Migrator;

static MIGRATOR: Migrator = sqlx::migrate!(); // Carrega de ./migrations

async fn executar_migrations(pool: &PgPool) -> Result<(), sqlx::Error> {
    MIGRATOR.run(pool).await?;
    println!("Migrations executadas com sucesso!");
    Ok(())
}

Queries dinâmicas com QueryBuilder

use sqlx::{PgPool, QueryBuilder, Postgres};

#[derive(Debug)]
struct FiltroUsuarios {
    nome: Option<String>,
    email: Option<String>,
    ativo: Option<bool>,
    limite: i64,
    offset: i64,
}

async fn buscar_usuarios(
    pool: &PgPool,
    filtro: &FiltroUsuarios,
) -> Result<Vec<(i32, String, String, bool)>, sqlx::Error> {
    let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
        "SELECT id, nome, email, ativo FROM usuarios WHERE 1=1"
    );

    if let Some(ref nome) = filtro.nome {
        query_builder.push(" AND nome ILIKE ");
        query_builder.push_bind(format!("%{}%", nome));
    }

    if let Some(ref email) = filtro.email {
        query_builder.push(" AND email ILIKE ");
        query_builder.push_bind(format!("%{}%", email));
    }

    if let Some(ativo) = filtro.ativo {
        query_builder.push(" AND ativo = ");
        query_builder.push_bind(ativo);
    }

    query_builder.push(" ORDER BY nome LIMIT ");
    query_builder.push_bind(filtro.limite);
    query_builder.push(" OFFSET ");
    query_builder.push_bind(filtro.offset);

    let query = query_builder.build_query_as::<(i32, String, String, bool)>();
    query.fetch_all(pool).await
}

Inserção em batch

use sqlx::{PgPool, QueryBuilder, Postgres};

struct NovoUsuario {
    nome: String,
    email: String,
}

async fn inserir_em_batch(
    pool: &PgPool,
    usuarios: &[NovoUsuario],
) -> Result<(), sqlx::Error> {
    if usuarios.is_empty() {
        return Ok(());
    }

    let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
        "INSERT INTO usuarios (nome, email, ativo, criado_em) "
    );

    query_builder.push_values(usuarios, |mut b, usuario| {
        b.push_bind(&usuario.nome)
            .push_bind(&usuario.email)
            .push("true")
            .push("NOW()");
    });

    let query = query_builder.build();
    query.execute(pool).await?;

    println!("{} usuários inseridos", usuarios.len());
    Ok(())
}

Streaming de resultados

Para grandes conjuntos de dados, use streams para não carregar tudo na memória:

use sqlx::PgPool;
use futures::TryStreamExt;

async fn processar_todos_usuarios(pool: &PgPool) -> Result<(), sqlx::Error> {
    let mut stream = sqlx::query!(
        "SELECT id, nome, email FROM usuarios ORDER BY id"
    )
    .fetch(pool);

    let mut contagem = 0;

    while let Some(registro) = stream.try_next().await? {
        contagem += 1;
        // Processar cada registro sem carregar todos na memória
        println!("[{}] {} - {}", registro.id, registro.nome, registro.email);
    }

    println!("Total processado: {}", contagem);
    Ok(())
}

Tipos customizados

use sqlx::{Type, FromRow, Decode, Encode, Postgres};

// Enum mapeado para tipo PostgreSQL
#[derive(Debug, Clone, Type)]
#[sqlx(type_name = "status_tarefa", rename_all = "snake_case")]
enum StatusTarefa {
    Pendente,
    EmProgresso,
    Concluida,
    Cancelada,
}

// Struct com tipos customizados
#[derive(Debug, FromRow)]
struct Tarefa {
    id: i32,
    titulo: String,
    status: StatusTarefa,
    prioridade: i16,
    criado_em: chrono::NaiveDateTime,
    metadata: Option<serde_json::Value>, // JSONB
    tags: Vec<String>,                   // TEXT[]
    responsavel_id: Option<uuid::Uuid>,  // UUID
}

async fn criar_tarefa_com_tipos(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
    let tags = vec!["urgente".to_string(), "backend".to_string()];
    let metadata = serde_json::json!({"sprint": 5, "pontos": 3});

    sqlx::query!(
        r#"
        INSERT INTO tarefas (titulo, status, prioridade, tags, metadata)
        VALUES ($1, $2::status_tarefa, $3, $4, $5)
        "#,
        "Implementar cache",
        StatusTarefa::Pendente as StatusTarefa,
        1_i16,
        &tags,
        metadata,
    )
    .execute(pool)
    .await?;

    Ok(())
}

Preparando para compile-time checks offline

# Gerar metadados para verificação offline
# (permite compilar sem conexão ao banco)
cargo sqlx prepare

# Isso cria o diretório .sqlx/ com os metadados das queries
# Comite este diretório no git para CI/CD

# Verificar se os metadados estão atualizados
cargo sqlx prepare --check

Boas Práticas

1. Sempre use connection pooling

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;

async fn criar_pool() -> Result<sqlx::PgPool, sqlx::Error> {
    PgPoolOptions::new()
        // Número máximo de conexões
        .max_connections(20)
        // Tempo mínimo de vida de uma conexão
        .min_connections(5)
        // Timeout para adquirir conexão do pool
        .acquire_timeout(Duration::from_secs(5))
        // Tempo máximo de vida de uma conexão
        .max_lifetime(Duration::from_secs(30 * 60))
        // Tempo máximo ociosa antes de ser fechada
        .idle_timeout(Duration::from_secs(10 * 60))
        // Testar conexão antes de entregar
        .test_before_acquire(true)
        .connect(&std::env::var("DATABASE_URL").expect("DATABASE_URL não definida"))
        .await
}

2. Use .env para configuração local

Crie um arquivo .env na raiz do projeto:

DATABASE_URL=postgres://usuario:senha@localhost:5432/meu_banco
// No início do main
fn main() {
    dotenvy::dotenv().ok();
    // ...
}

3. Separe a camada de repositório

use sqlx::PgPool;

// Trait de repositório para abstração
#[async_trait::async_trait]
trait UsuarioRepo {
    async fn criar(&self, nome: &str, email: &str) -> Result<i32, sqlx::Error>;
    async fn obter(&self, id: i32) -> Result<Option<Usuario>, sqlx::Error>;
    async fn listar(&self, limite: i64) -> Result<Vec<Usuario>, sqlx::Error>;
}

// Implementação concreta com PostgreSQL
struct PgUsuarioRepo {
    pool: PgPool,
}

#[async_trait::async_trait]
impl UsuarioRepo for PgUsuarioRepo {
    async fn criar(&self, nome: &str, email: &str) -> Result<i32, sqlx::Error> {
        let registro = sqlx::query!(
            "INSERT INTO usuarios (nome, email) VALUES ($1, $2) RETURNING id",
            nome,
            email,
        )
        .fetch_one(&self.pool)
        .await?;

        Ok(registro.id)
    }

    async fn obter(&self, id: i32) -> Result<Option<Usuario>, sqlx::Error> {
        sqlx::query_as!(
            Usuario,
            "SELECT id, nome, email, ativo, criado_em FROM usuarios WHERE id = $1",
            id,
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn listar(&self, limite: i64) -> Result<Vec<Usuario>, sqlx::Error> {
        sqlx::query_as!(
            Usuario,
            "SELECT id, nome, email, ativo, criado_em FROM usuarios ORDER BY nome LIMIT $1",
            limite,
        )
        .fetch_all(&self.pool)
        .await
    }
}

4. Trate erros de banco adequadamente

use sqlx::Error as SqlxError;

#[derive(Debug)]
enum AppErro {
    NaoEncontrado(String),
    Duplicado(String),
    BancoDeDados(SqlxError),
    Validacao(String),
}

impl From<SqlxError> for AppErro {
    fn from(err: SqlxError) -> Self {
        match &err {
            SqlxError::RowNotFound => {
                AppErro::NaoEncontrado("Registro não encontrado".to_string())
            }
            SqlxError::Database(db_err) => {
                // Código de erro PostgreSQL para unique_violation
                if db_err.code().map(|c| c == "23505").unwrap_or(false) {
                    AppErro::Duplicado(
                        db_err.message().to_string()
                    )
                } else {
                    AppErro::BancoDeDados(err)
                }
            }
            _ => AppErro::BancoDeDados(err),
        }
    }
}

5. Use sqlx prepare para CI/CD

# .github/workflows/ci.yml
- name: Verificar queries SQLx
  run: cargo sqlx prepare --check

Exemplos Práticos

CRUD completo com PostgreSQL e Axum

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post, put, delete},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};

// === Modelos ===

#[derive(Debug, Serialize, FromRow)]
struct Produto {
    id: i32,
    nome: String,
    descricao: Option<String>,
    preco: f64,
    estoque: i32,
    ativo: bool,
}

#[derive(Debug, Deserialize)]
struct CriarProduto {
    nome: String,
    descricao: Option<String>,
    preco: f64,
    estoque: i32,
}

#[derive(Debug, Deserialize)]
struct AtualizarProduto {
    nome: Option<String>,
    descricao: Option<String>,
    preco: Option<f64>,
    estoque: Option<i32>,
    ativo: Option<bool>,
}

// === Estado da aplicação ===

#[derive(Clone)]
struct AppState {
    pool: PgPool,
}

// === Handlers ===

async fn listar_produtos(
    State(state): State<AppState>,
) -> Result<Json<Vec<Produto>>, StatusCode> {
    let produtos = sqlx::query_as!(
        Produto,
        "SELECT id, nome, descricao, preco, estoque, ativo FROM produtos WHERE ativo = true ORDER BY nome"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(produtos))
}

async fn obter_produto(
    State(state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<Json<Produto>, StatusCode> {
    let produto = sqlx::query_as!(
        Produto,
        "SELECT id, nome, descricao, preco, estoque, ativo FROM produtos WHERE id = $1",
        id,
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(produto))
}

async fn criar_produto(
    State(state): State<AppState>,
    Json(novo): Json<CriarProduto>,
) -> Result<(StatusCode, Json<Produto>), StatusCode> {
    let produto = sqlx::query_as!(
        Produto,
        r#"
        INSERT INTO produtos (nome, descricao, preco, estoque, ativo)
        VALUES ($1, $2, $3, $4, true)
        RETURNING id, nome, descricao, preco, estoque, ativo
        "#,
        novo.nome,
        novo.descricao,
        novo.preco,
        novo.estoque,
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(produto)))
}

async fn atualizar_produto(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(dados): Json<AtualizarProduto>,
) -> Result<Json<Produto>, StatusCode> {
    let produto = sqlx::query_as!(
        Produto,
        r#"
        UPDATE produtos SET
            nome = COALESCE($2, nome),
            descricao = COALESCE($3, descricao),
            preco = COALESCE($4, preco),
            estoque = COALESCE($5, estoque),
            ativo = COALESCE($6, ativo)
        WHERE id = $1
        RETURNING id, nome, descricao, preco, estoque, ativo
        "#,
        id,
        dados.nome,
        dados.descricao,
        dados.preco,
        dados.estoque,
        dados.ativo,
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(produto))
}

async fn deletar_produto(
    State(state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
    let resultado = sqlx::query!(
        "DELETE FROM produtos WHERE id = $1",
        id,
    )
    .execute(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if resultado.rows_affected() == 0 {
        return Err(StatusCode::NOT_FOUND);
    }

    Ok(StatusCode::NO_CONTENT)
}

// === Ponto de entrada ===

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenvy::dotenv().ok();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL deve estar definida");

    let pool = sqlx::postgres::PgPoolOptions::new()
        .max_connections(10)
        .connect(&database_url)
        .await?;

    // Executar migrations
    sqlx::migrate!().run(&pool).await?;

    let state = AppState { pool };

    let app = Router::new()
        .route("/produtos", get(listar_produtos).post(criar_produto))
        .route(
            "/produtos/{id}",
            get(obter_produto)
                .put(atualizar_produto)
                .delete(deletar_produto),
        )
        .with_state(state);

    println!("Servidor rodando em http://0.0.0.0:3000");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;

    Ok(())
}

Comparação com Alternativas

CaracterísticaSQLxDieselSeaORMRusqlite
AbordagemSQL puro verificadoORM com DSLORM com ActiveModelBindings SQLite
Compile-time checkSim (query!())Sim (schema DSL)NãoNão
AsyncSim (nativo)Não (sync)SimNão (sync)
Bancos suportadosPG, MySQL, SQLitePG, MySQL, SQLitePG, MySQL, SQLiteApenas SQLite
SQL puroSimNão (DSL)OpcionalSim
MigrationsIntegradodiesel_cliIntegrado (sea-orm-cli)Manual
Connection poolEmbutidoVia r2d2EmbutidoNão aplicável
Curva de aprendizadoBaixa (sabe SQL)Média (aprender DSL)MédiaBaixa
  • SQLx vs Diesel: SQLx usa SQL puro e é assíncrono. Diesel usa uma DSL tipada e é síncrono. Se prefere SQL puro e precisa de async, escolha SQLx. Se quer mais abstração e type-safety no query building, escolha Diesel.
  • SQLx vs SeaORM: SeaORM é construído sobre SQLx e adiciona uma camada ORM. Use SeaORM se prefere o padrão ActiveModel/ActiveRecord.
  • SQLx vs Rusqlite: Rusqlite é focado exclusivamente em SQLite e é síncrono. Se só precisa de SQLite local sem async, Rusqlite é mais simples.

Conclusão

O SQLx oferece o melhor dos dois mundos: a flexibilidade do SQL puro com a segurança de tipos de um ORM. A verificação em compile-time é uma funcionalidade revolucionária que elimina uma classe inteira de bugs em tempo de execução.

Para a maioria dos projetos web em Rust, o SQLx é a escolha mais equilibrada: poderoso o suficiente para qualquer query complexa, seguro o suficiente para produção, e ergonômico o suficiente para não atrapalhar o desenvolvimento.

Próximos passos

  • Explore o Diesel para uma abordagem ORM alternativa
  • Use o Axum para construir APIs REST com SQLx
  • Configure Tracing para observar queries lentas em produção
  • Aprenda sobre migrations para gerenciar a evolução do schema