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ística | SQLx | Diesel | SeaORM | Rusqlite |
|---|---|---|---|---|
| Abordagem | SQL puro verificado | ORM com DSL | ORM com ActiveModel | Bindings SQLite |
| Compile-time check | Sim (query!()) | Sim (schema DSL) | Não | Não |
| Async | Sim (nativo) | Não (sync) | Sim | Não (sync) |
| Bancos suportados | PG, MySQL, SQLite | PG, MySQL, SQLite | PG, MySQL, SQLite | Apenas SQLite |
| SQL puro | Sim | Não (DSL) | Opcional | Sim |
| Migrations | Integrado | diesel_cli | Integrado (sea-orm-cli) | Manual |
| Connection pool | Embutido | Via r2d2 | Embutido | Não aplicável |
| Curva de aprendizado | Baixa (sabe SQL) | Média (aprender DSL) | Média | Baixa |
- 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