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 errofetch_optional— retornaOption<T>, ideal para buscas que podem não encontrar resultadosfetch_all— retornaVec<T>com todos os registrosexecute— 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 dequery_asquando 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
.envcomdotenvye adicione.envao.gitignore. - Defina índices para colunas usadas frequentemente em WHERE e ORDER BY.
- Use
RETURNINGem 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.