Rust com PostgreSQL: Tutorial Completo com SQLx

Tutorial completo de Rust com PostgreSQL usando SQLx. Aprenda CRUD, migrations, transactions e boas práticas.

Introdução

PostgreSQL é um dos bancos de dados relacionais mais robustos e populares do mercado. Combinado com Rust, temos uma stack de altíssima performance e segurança. Neste tutorial, vamos usar o SQLx — uma biblioteca async e compile-time checked para interagir com PostgreSQL de forma idiomática em Rust. Ao final, você terá um sistema completo de gerenciamento de usuários com CRUD, migrations, transactions e testes.

Pré-requisitos

  • Rust instalado (1.75+)
  • PostgreSQL instalado localmente ou via Docker
  • Conhecimentos básicos de Rust e SQL

Configurando o Ambiente com Docker

A forma mais prática de rodar PostgreSQL localmente é com Docker. Crie um arquivo docker-compose.yml na raiz do projeto:

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    container_name: rust_postgres_db
    environment:
      POSTGRES_USER: rust_user
      POSTGRES_PASSWORD: rust_senha_segura
      POSTGRES_DB: rust_tutorial
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Suba o container:

docker compose up -d

Verifique se o banco está rodando:

docker compose ps

Configurando o Projeto Rust

Crie um novo projeto e adicione as dependências necessárias:

cargo new rust-postgresql
cd rust-postgresql
// Cargo.toml
// [package]
// name = "rust-postgresql"
// version = "0.1.0"
// edition = "2021"
//
// [dependencies]
// sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] }
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// dotenvy = "0.15"
// uuid = { version = "1", features = ["v4", "serde"] }
// chrono = { version = "0.4", features = ["serde"] }
// thiserror = "2"

Instale também o CLI do SQLx para gerenciar migrations:

cargo install sqlx-cli --no-default-features --features postgres

Configurando a Conexão com .env

Crie um arquivo .env na raiz do projeto com a URL de conexão:

# .env
DATABASE_URL=postgres://rust_user:rust_senha_segura@localhost:5432/rust_tutorial

O SQLx usa essa variável tanto em tempo de compilação (para verificar queries) quanto em tempo de execução.

Conexão com o Banco de Dados

Vamos criar um módulo de conexão usando PgPool, que gerencia um pool de conexões reutilizáveis — essencial para aplicações de alta performance:

// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

pub async fn criar_pool() -> Result<PgPool, sqlx::Error> {
    dotenvy::dotenv().ok();

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

    let pool = PgPoolOptions::new()
        .max_connections(10)
        .min_connections(2)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .connect(&database_url)
        .await?;

    println!("Conexão com PostgreSQL estabelecida com sucesso!");
    Ok(pool)
}

O PgPool gerencia automaticamente a criação e reuso de conexões. Configuramos no máximo 10 conexões simultâneas e no mínimo 2 conexões ativas, com timeout de 5 segundos para aquisição.

Migrations com sqlx-cli

Migrations permitem versionar o esquema do banco de dados. Crie a primeira migration:

sqlx migrate add criar_usuarios

Isso cria um arquivo em migrations/<timestamp>_criar_usuarios.sql. Edite-o:

-- migrations/<timestamp>_criar_usuarios.sql

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE usuarios (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    nome VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    idade INT,
    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);

Execute a migration:

sqlx migrate run

Para reverter, crie migrations reversíveis usando sqlx migrate add -r <nome>, que gera arquivos .up.sql e .down.sql.

Você também pode executar migrations programaticamente na inicialização da aplicação:

// Executar migrations no startup
sqlx::migrate!("./migrations")
    .run(&pool)
    .await
    .expect("Falha ao executar migrations");

Definindo os Modelos

Crie structs que representam os dados do banco, usando derives do SQLx e do Serde:

// src/models.rs
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

/// Representa um usuário no banco de dados.
#[derive(Debug, Serialize, FromRow)]
pub struct Usuario {
    pub id: Uuid,
    pub nome: String,
    pub email: String,
    pub idade: Option<i32>,
    pub ativo: bool,
    pub criado_em: NaiveDateTime,
    pub atualizado_em: NaiveDateTime,
}

/// Dados para criar um novo usuário.
#[derive(Debug, Deserialize)]
pub struct CriarUsuario {
    pub nome: String,
    pub email: String,
    pub idade: Option<i32>,
}

/// Dados para atualizar um usuário existente.
#[derive(Debug, Deserialize)]
pub struct AtualizarUsuario {
    pub nome: Option<String>,
    pub email: Option<String>,
    pub idade: Option<i32>,
    pub ativo: Option<bool>,
}

O derive FromRow permite que o SQLx mapeie automaticamente as colunas do resultado SQL para os campos da struct. O derive Serialize é usado para converter a struct em JSON quando necessário.

Operações CRUD

Agora vamos implementar todas as operações de banco de dados em um módulo dedicado:

// src/repositorio.rs
use sqlx::PgPool;
use uuid::Uuid;

use crate::models::{AtualizarUsuario, CriarUsuario, Usuario};

/// Insere um novo usuário no banco de dados.
pub async fn inserir_usuario(
    pool: &PgPool,
    dados: &CriarUsuario,
) -> Result<Usuario, sqlx::Error> {
    let usuario = sqlx::query_as::<_, Usuario>(
        r#"
        INSERT INTO usuarios (nome, email, idade)
        VALUES ($1, $2, $3)
        RETURNING id, nome, email, idade, ativo, criado_em, atualizado_em
        "#,
    )
    .bind(&dados.nome)
    .bind(&dados.email)
    .bind(dados.idade)
    .fetch_one(pool)
    .await?;

    Ok(usuario)
}

/// Busca um usuário pelo ID.
pub async fn buscar_por_id(
    pool: &PgPool,
    id: Uuid,
) -> Result<Option<Usuario>, sqlx::Error> {
    let usuario = sqlx::query_as::<_, Usuario>(
        "SELECT id, nome, email, idade, ativo, criado_em, atualizado_em FROM usuarios WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(usuario)
}

/// Busca um usuário pelo email.
pub async fn buscar_por_email(
    pool: &PgPool,
    email: &str,
) -> Result<Option<Usuario>, sqlx::Error> {
    let usuario = sqlx::query_as::<_, Usuario>(
        "SELECT id, nome, email, idade, ativo, criado_em, atualizado_em FROM usuarios WHERE email = $1",
    )
    .bind(email)
    .fetch_optional(pool)
    .await?;

    Ok(usuario)
}

/// Lista todos os usuários com paginação.
pub async fn listar_usuarios(
    pool: &PgPool,
    limite: i64,
    offset: i64,
) -> Result<Vec<Usuario>, sqlx::Error> {
    let usuarios = sqlx::query_as::<_, Usuario>(
        r#"
        SELECT id, nome, email, idade, ativo, criado_em, atualizado_em
        FROM usuarios
        ORDER BY criado_em DESC
        LIMIT $1 OFFSET $2
        "#,
    )
    .bind(limite)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    Ok(usuarios)
}

/// Atualiza um usuário existente.
pub async fn atualizar_usuario(
    pool: &PgPool,
    id: Uuid,
    dados: &AtualizarUsuario,
) -> Result<Option<Usuario>, sqlx::Error> {
    let usuario = sqlx::query_as::<_, Usuario>(
        r#"
        UPDATE usuarios
        SET
            nome = COALESCE($1, nome),
            email = COALESCE($2, email),
            idade = COALESCE($3, idade),
            ativo = COALESCE($4, ativo),
            atualizado_em = NOW()
        WHERE id = $5
        RETURNING id, nome, email, idade, ativo, criado_em, atualizado_em
        "#,
    )
    .bind(&dados.nome)
    .bind(&dados.email)
    .bind(dados.idade)
    .bind(dados.ativo)
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(usuario)
}

/// Remove um usuário pelo ID. Retorna true se o usuário foi removido.
pub async fn deletar_usuario(
    pool: &PgPool,
    id: Uuid,
) -> Result<bool, sqlx::Error> {
    let resultado = sqlx::query("DELETE FROM usuarios WHERE id = $1")
        .bind(id)
        .execute(pool)
        .await?;

    Ok(resultado.rows_affected() > 0)
}

/// Conta o total de usuários ativos.
pub async fn contar_usuarios_ativos(pool: &PgPool) -> Result<i64, sqlx::Error> {
    let (total,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM usuarios WHERE ativo = TRUE")
            .fetch_one(pool)
            .await?;

    Ok(total)
}

Pontos importantes sobre as operações:

  • fetch_one — retorna exatamente um registro ou erro
  • fetch_optional — retorna Option<T>, ideal para buscas que podem não encontrar resultados
  • fetch_all — retorna Vec<T> com todos os registros
  • execute — para operações que não retornam dados (DELETE, UPDATE sem RETURNING)
  • RETURNING — cláusula do PostgreSQL que retorna o registro afetado após INSERT/UPDATE

Transactions

Transactions garantem que um conjunto de operações seja executado de forma atômica — ou todas são confirmadas, ou nenhuma é. Isso é essencial para manter a integridade dos dados:

// src/transacoes.rs
use sqlx::PgPool;

use crate::models::{CriarUsuario, Usuario};

/// Cria múltiplos usuários em uma única transação.
/// Se qualquer inserção falhar, todas são revertidas.
pub async fn criar_usuarios_em_lote(
    pool: &PgPool,
    usuarios: &[CriarUsuario],
) -> Result<Vec<Usuario>, sqlx::Error> {
    let mut tx = pool.begin().await?;
    let mut criados = Vec::new();

    for dados in usuarios {
        let usuario = sqlx::query_as::<_, Usuario>(
            r#"
            INSERT INTO usuarios (nome, email, idade)
            VALUES ($1, $2, $3)
            RETURNING id, nome, email, idade, ativo, criado_em, atualizado_em
            "#,
        )
        .bind(&dados.nome)
        .bind(&dados.email)
        .bind(dados.idade)
        .fetch_one(&mut *tx)
        .await?;

        criados.push(usuario);
    }

    // Confirma todas as operações
    tx.commit().await?;
    println!("{} usuários criados com sucesso!", criados.len());

    Ok(criados)
}

/// Transfere o status "ativo" de um usuário para outro, de forma atômica.
pub async fn transferir_status(
    pool: &PgPool,
    de_id: uuid::Uuid,
    para_id: uuid::Uuid,
) -> Result<(), sqlx::Error> {
    let mut tx = pool.begin().await?;

    sqlx::query("UPDATE usuarios SET ativo = FALSE, atualizado_em = NOW() WHERE id = $1")
        .bind(de_id)
        .execute(&mut *tx)
        .await?;

    sqlx::query("UPDATE usuarios SET ativo = TRUE, atualizado_em = NOW() WHERE id = $1")
        .bind(para_id)
        .execute(&mut *tx)
        .await?;

    tx.commit().await?;
    Ok(())
}

Note que passamos &mut *tx para as queries dentro da transação. Se ocorrer um erro antes do commit(), a transação é automaticamente revertida quando tx é descartado (drop).

Tratamento de Erros

Crie um tipo de erro personalizado para a aplicação, encapsulando os erros do SQLx de forma mais amigável:

// src/erros.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppErro {
    #[error("Erro no banco de dados: {0}")]
    BancoDeDados(#[from] sqlx::Error),

    #[error("Usuário não encontrado com id: {0}")]
    NaoEncontrado(uuid::Uuid),

    #[error("Email já cadastrado: {0}")]
    EmailDuplicado(String),

    #[error("Dados inválidos: {0}")]
    Validacao(String),
}

impl AppErro {
    /// Verifica se o erro do SQLx é uma violação de unicidade (email duplicado).
    pub fn de_sqlx(err: sqlx::Error, contexto: &str) -> Self {
        match &err {
            sqlx::Error::Database(db_err) => {
                if db_err.constraint() == Some("usuarios_email_key") {
                    return AppErro::EmailDuplicado(contexto.to_string());
                }
                AppErro::BancoDeDados(err)
            }
            _ => AppErro::BancoDeDados(err),
        }
    }
}

Uso no repositório com tratamento adequado:

pub async fn inserir_usuario_seguro(
    pool: &PgPool,
    dados: &CriarUsuario,
) -> Result<Usuario, AppErro> {
    if dados.nome.trim().is_empty() {
        return Err(AppErro::Validacao("Nome não pode ser vazio".into()));
    }

    if !dados.email.contains('@') {
        return Err(AppErro::Validacao("Email inválido".into()));
    }

    let usuario = sqlx::query_as::<_, Usuario>(
        r#"
        INSERT INTO usuarios (nome, email, idade)
        VALUES ($1, $2, $3)
        RETURNING id, nome, email, idade, ativo, criado_em, atualizado_em
        "#,
    )
    .bind(&dados.nome)
    .bind(&dados.email)
    .bind(dados.idade)
    .fetch_one(pool)
    .await
    .map_err(|e| AppErro::de_sqlx(e, &dados.email))?;

    Ok(usuario)
}

Exemplo Completo

Vamos juntar tudo em um main.rs funcional que demonstra todas as operações:

// src/main.rs
mod db;
mod erros;
mod models;
mod repositorio;
mod transacoes;

use models::{AtualizarUsuario, CriarUsuario};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Conectar ao banco
    let pool = db::criar_pool().await?;

    // 2. Executar migrations
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await?;
    println!("Migrations executadas com sucesso!");

    // 3. Inserir usuários
    let alice = repositorio::inserir_usuario(&pool, &CriarUsuario {
        nome: "Alice Silva".into(),
        email: "alice@exemplo.com".into(),
        idade: Some(30),
    }).await?;
    println!("Usuário criado: {} ({})", alice.nome, alice.id);

    let bob = repositorio::inserir_usuario(&pool, &CriarUsuario {
        nome: "Bob Santos".into(),
        email: "bob@exemplo.com".into(),
        idade: Some(25),
    }).await?;
    println!("Usuário criado: {} ({})", bob.nome, bob.id);

    // 4. Buscar por ID
    if let Some(usuario) = repositorio::buscar_por_id(&pool, alice.id).await? {
        println!("Encontrado: {} - {}", usuario.nome, usuario.email);
    }

    // 5. Buscar por email
    if let Some(usuario) = repositorio::buscar_por_email(&pool, "bob@exemplo.com").await? {
        println!("Encontrado por email: {}", usuario.nome);
    }

    // 6. Listar todos com paginação
    let todos = repositorio::listar_usuarios(&pool, 10, 0).await?;
    println!("\nTodos os usuários ({}):", todos.len());
    for u in &todos {
        println!("  - {} ({}) | ativo: {}", u.nome, u.email, u.ativo);
    }

    // 7. Atualizar usuário
    let atualizado = repositorio::atualizar_usuario(&pool, alice.id, &AtualizarUsuario {
        nome: Some("Alice Oliveira".into()),
        email: None,
        idade: Some(31),
        ativo: None,
    }).await?;
    if let Some(u) = atualizado {
        println!("\nAtualizado: {} (idade: {:?})", u.nome, u.idade);
    }

    // 8. Criar em lote com transação
    let novos = vec![
        CriarUsuario {
            nome: "Carlos Lima".into(),
            email: "carlos@exemplo.com".into(),
            idade: Some(28),
        },
        CriarUsuario {
            nome: "Diana Costa".into(),
            email: "diana@exemplo.com".into(),
            idade: None,
        },
    ];
    let criados = transacoes::criar_usuarios_em_lote(&pool, &novos).await?;
    println!("\nCriados em lote: {}", criados.len());

    // 9. Contar ativos
    let total = repositorio::contar_usuarios_ativos(&pool).await?;
    println!("Total de usuários ativos: {}", total);

    // 10. Deletar usuário
    let removido = repositorio::deletar_usuario(&pool, bob.id).await?;
    println!("\nBob removido: {}", removido);

    // 11. Verificar contagem final
    let total_final = repositorio::contar_usuarios_ativos(&pool).await?;
    println!("Total de usuários ativos após remoção: {}", total_final);

    Ok(())
}

Estrutura final do projeto:

rust-postgresql/
├── Cargo.toml
├── .env
├── docker-compose.yml
├── migrations/
│   └── 20260223000000_criar_usuarios.sql
└── src/
    ├── main.rs
    ├── db.rs
    ├── models.rs
    ├── repositorio.rs
    ├── transacoes.rs
    └── erros.rs

Testando o Código de Banco de Dados

Testar código que interage com banco de dados requer um banco real. A estratégia recomendada é usar um banco de testes dedicado e executar cada teste dentro de uma transação que é revertida ao final:

// tests/integration_test.rs
use sqlx::PgPool;

/// Helper: cria um pool de teste usando DATABASE_URL do .env
async fn pool_de_teste() -> PgPool {
    dotenvy::dotenv().ok();
    let url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL deve estar definida");
    PgPool::connect(&url).await.expect("Falha ao conectar ao banco de testes")
}

#[sqlx::test]
async fn test_inserir_e_buscar_usuario(pool: PgPool) {
    // sqlx::test cria automaticamente um banco temporário e roda migrations

    let dados = rust_postgresql::models::CriarUsuario {
        nome: "Teste User".into(),
        email: "teste@exemplo.com".into(),
        idade: Some(25),
    };

    // Inserir
    let usuario = rust_postgresql::repositorio::inserir_usuario(&pool, &dados)
        .await
        .expect("Falha ao inserir usuário");

    assert_eq!(usuario.nome, "Teste User");
    assert_eq!(usuario.email, "teste@exemplo.com");
    assert_eq!(usuario.idade, Some(25));
    assert!(usuario.ativo);

    // Buscar por ID
    let encontrado = rust_postgresql::repositorio::buscar_por_id(&pool, usuario.id)
        .await
        .expect("Falha ao buscar usuário")
        .expect("Usuário não encontrado");

    assert_eq!(encontrado.id, usuario.id);
    assert_eq!(encontrado.nome, "Teste User");
}

#[sqlx::test]
async fn test_atualizar_usuario(pool: PgPool) {
    let dados = rust_postgresql::models::CriarUsuario {
        nome: "Original".into(),
        email: "original@exemplo.com".into(),
        idade: Some(20),
    };

    let usuario = rust_postgresql::repositorio::inserir_usuario(&pool, &dados)
        .await
        .unwrap();

    let atualizacao = rust_postgresql::models::AtualizarUsuario {
        nome: Some("Atualizado".into()),
        email: None,
        idade: Some(21),
        ativo: None,
    };

    let atualizado = rust_postgresql::repositorio::atualizar_usuario(
        &pool, usuario.id, &atualizacao
    )
    .await
    .unwrap()
    .expect("Usuário não encontrado para atualização");

    assert_eq!(atualizado.nome, "Atualizado");
    assert_eq!(atualizado.email, "original@exemplo.com"); // não mudou
    assert_eq!(atualizado.idade, Some(21));
}

#[sqlx::test]
async fn test_deletar_usuario(pool: PgPool) {
    let dados = rust_postgresql::models::CriarUsuario {
        nome: "Para Deletar".into(),
        email: "deletar@exemplo.com".into(),
        idade: None,
    };

    let usuario = rust_postgresql::repositorio::inserir_usuario(&pool, &dados)
        .await
        .unwrap();

    let removido = rust_postgresql::repositorio::deletar_usuario(&pool, usuario.id)
        .await
        .unwrap();
    assert!(removido);

    // Verificar que foi removido
    let busca = rust_postgresql::repositorio::buscar_por_id(&pool, usuario.id)
        .await
        .unwrap();
    assert!(busca.is_none());
}

#[sqlx::test]
async fn test_email_duplicado(pool: PgPool) {
    let dados = rust_postgresql::models::CriarUsuario {
        nome: "Primeiro".into(),
        email: "duplicado@exemplo.com".into(),
        idade: None,
    };

    rust_postgresql::repositorio::inserir_usuario(&pool, &dados)
        .await
        .unwrap();

    // Tentar inserir com mesmo email deve falhar
    let resultado = rust_postgresql::repositorio::inserir_usuario(&pool, &dados).await;
    assert!(resultado.is_err());
}

#[sqlx::test]
async fn test_listar_com_paginacao(pool: PgPool) {
    // Inserir 3 usuários
    for i in 0..3 {
        let dados = rust_postgresql::models::CriarUsuario {
            nome: format!("User {}", i),
            email: format!("user{}@exemplo.com", i),
            idade: Some(20 + i),
        };
        rust_postgresql::repositorio::inserir_usuario(&pool, &dados)
            .await
            .unwrap();
    }

    // Buscar com limite 2
    let pagina1 = rust_postgresql::repositorio::listar_usuarios(&pool, 2, 0)
        .await
        .unwrap();
    assert_eq!(pagina1.len(), 2);

    // Buscar segunda página
    let pagina2 = rust_postgresql::repositorio::listar_usuarios(&pool, 2, 2)
        .await
        .unwrap();
    assert_eq!(pagina2.len(), 1);
}

O macro #[sqlx::test] é extremamente poderoso: ele cria automaticamente um banco de dados temporário, executa todas as migrations, injeta o PgPool no teste e limpa tudo ao final. Execute os testes com:

cargo test

Para rodar os testes, garanta que o DATABASE_URL aponta para um servidor PostgreSQL acessível. O sqlx::test cria e destrói bancos temporários automaticamente, então seus dados de desenvolvimento não são afetados.

Dicas e Boas Práticas

  • Use query_as! em vez de query_as quando possível — a versão com ! verifica as queries em tempo de compilação contra o banco real, pegando erros de SQL antes de rodar o programa.
  • Sempre use pool de conexões (PgPool) em vez de conexões individuais. O pool gerencia a reutilização e evita abrir conexões desnecessárias.
  • Use transactions para operações que envolvem múltiplas escritas relacionadas. Se uma falhar, todas são revertidas.
  • Nunca coloque credenciais no código-fonte. Use .env com dotenvy e adicione .env ao .gitignore.
  • Defina índices para colunas usadas frequentemente em WHERE e ORDER BY.
  • Use RETURNING em INSERT/UPDATE para evitar uma query extra de SELECT.
  • Trate erros de constraint (como email duplicado) de forma amigável para o usuário.

Conclusão

Neste tutorial, construímos uma aplicação completa em Rust com PostgreSQL usando SQLx. Cobrimos desde a configuração do ambiente com Docker até testes de integração, passando por CRUD completo, transactions e tratamento de erros. O SQLx oferece uma experiência excelente para trabalhar com bancos de dados em Rust: queries verificadas em tempo de compilação, suporte async nativo e mapeamento automático de tipos. Com essas bases, você está pronto para construir aplicações robustas e seguras com Rust e PostgreSQL.