SQLModel reúne modelos tipados, validação e persistência sobre Pydantic e SQLAlchemy. A redução de repetição é útil em APIs, mas não elimina decisões sobre transações, índices, relacionamentos e migrações.

Criar tabela e sessão

python -m pip install sqlmodel
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Produto(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    nome: str = Field(index=True, min_length=2, max_length=120)
    preco_centavos: int = Field(gt=0)


engine = create_engine("sqlite:///loja.db")
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
    produto = Produto(nome="Teclado", preco_centavos=15900)
    session.add(produto)
    session.commit()
    session.refresh(produto)

    encontrados = session.exec(select(Produto).where(Produto.preco_centavos < 20000)).all()

Guarde dinheiro como inteiro na menor unidade ou use Decimal com configuração coerente no banco. O artigo sobre Decimal para cálculos monetários explica o motivo.

Separar contratos da tabela

Crie ProdutoCreate sem table=True, ProdutoUpdate com campos opcionais e ProdutoPublic somente com campos que podem sair da API. Essa separação impede que clientes definam IDs internos ou recebam informações sensíveis. Em FastAPI, forneça uma sessão curta por requisição, como mostra o guia de injeção de dependências.

create_all() ajuda em exemplos e testes, mas não controla a evolução de um banco existente. Use Alembic para migrações, revise o SQL gerado e mantenha backup.

A documentação oficial do SQLModel, consultada em 22 de julho de 2026, cobre CRUD, relacionamentos e integração com FastAPI. SQLModel é uma camada conveniente, não um substituto para entender constraints, transações e consultas produzidas.

Modelar contratos sem expor a tabela

Um CRUD real precisa distinguir o que chega do cliente, o que existe no banco e o que pode voltar na resposta. Uma base compartilhada reduz repetição sem transformar a tabela em contrato público:

class ProdutoBase(SQLModel):
    nome: str = Field(min_length=2, max_length=120)
    preco_centavos: int = Field(gt=0)

class Produto(ProdutoBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    ativo: bool = True

class ProdutoCreate(ProdutoBase):
    pass

class ProdutoUpdate(SQLModel):
    nome: str | None = Field(default=None, min_length=2, max_length=120)
    preco_centavos: int | None = Field(default=None, gt=0)
    ativo: bool | None = None

class ProdutoPublic(ProdutoBase):
    id: int
    ativo: bool

Essa divisão impede que um POST escolha o id e permite que a atualização parcial diferencie campo ausente de valor informado. Para aplicar um PATCH, obtenha apenas os campos enviados com model_dump(exclude_unset=True) e atribua cada valor ao registro carregado. Não use exclude_none=True automaticamente: em outros modelos, null pode significar a intenção legítima de apagar um valor opcional.

Implementar criação, leitura, atualização e exclusão

Mantenha a transação perto da operação. Uma função de criação pode receber a sessão e o contrato validado, construir Produto.model_validate(dados), adicionar, confirmar e executar refresh() antes de devolver o objeto. O refresh() é importante para carregar o identificador e valores definidos pelo banco.

Na leitura individual, procure pela chave primária e devolva 404 quando ela não existir. Não retorne None com status 200, pois isso torna o contrato ambíguo. Para listas, imponha paginação:

def listar_produtos(session: Session, offset: int = 0, limite: int = 50):
    consulta = (
        select(Produto)
        .where(Produto.ativo.is_(True))
        .order_by(Produto.id)
        .offset(offset)
        .limit(min(limite, 100))
    )
    return session.exec(consulta).all()

Ordenação explícita evita páginas instáveis. offset é simples e atende catálogos modestos; conjuntos grandes ou muito atualizados podem exigir paginação por cursor. O índice deve acompanhar filtros frequentes, mas cada índice aumenta custo de escrita e espaço. Confirme o plano de execução no banco em vez de adicionar índices por intuição.

Na atualização, carregue o registro dentro da mesma sessão, aplique os campos aceitos, confirme e atualize o objeto. Em caso de constraint única, capture a exceção específica do banco, faça rollback() e traduza o conflito para uma resposta 409. Uma sessão que falhou não deve ser reutilizada antes do rollback.

Excluir fisicamente é adequado para dados descartáveis. Para produtos referenciados por pedidos, normalmente é mais seguro marcar ativo=False. A exclusão lógica exige que todas as consultas relevantes filtrem registros inativos; centralize essa regra para não ocultar um produto em uma tela e exibi-lo em outra.

Integrar a sessão ao FastAPI

Crie uma dependência que abra e feche a sessão por requisição:

def obter_sessao():
    with Session(engine) as session:
        yield session

O endpoint recebe session: Session = Depends(obter_sessao). A sessão não é um cache global e não deve atravessar threads ou ser guardada entre requisições. Para SQLite em testes com múltiplas threads, a configuração pode ser diferente da produção; não copie check_same_thread=False para outro banco sem entender sua finalidade.

Defina response_model=ProdutoPublic ou a anotação de retorno equivalente. Assim, a documentação OpenAPI descreve a resposta pública e campos internos não vazam por acidente. Validação de formato, porém, não substitui autorização: antes de ler ou alterar um registro, confirme se o usuário pode operar aquele recurso.

Transações, concorrência e consistência

Uma chamada a commit() define uma unidade de trabalho. Se criar um pedido e reduzir estoque precisam acontecer juntos, faça as duas alterações na mesma transação. Confirmar no meio e tentar compensar depois abre espaço para estados incompletos.

Duas requisições podem ler o mesmo valor e tentar atualizá-lo. Constraints no banco são a última linha de defesa para unicidade e integridade referencial. Para disputas de estoque ou saldo, estude atualização atômica, bloqueio ou controle otimista com uma coluna de versão. O comportamento correto depende do banco e do nível de isolamento, não apenas do ORM.

Evite o problema N+1 ao percorrer relacionamentos e disparar uma consulta para cada linha. Observe o SQL durante o desenvolvimento e escolha carregamento explícito quando precisar dos dados relacionados. Também não devolva uma coleção ilimitada apenas porque o ORM facilita .all().

Testar sem mascarar problemas

Nos testes, substitua a dependência de sessão por um banco temporário e crie as tabelas para cada cenário isolado. Teste sucesso, validação, item inexistente, conflito de unicidade, paginação e autorização. Um teste que chama apenas a função do repositório não garante que o endpoint serializa o modelo público correto.

SQLite é ótimo para testes rápidos, mas possui tipos e concorrência diferentes de PostgreSQL ou MySQL. Mantenha uma camada de testes de integração no mesmo mecanismo usado em produção para consultas, constraints e migrações críticas. Nunca execute create_all() como estratégia de atualização: ele cria o que falta, mas não registra a história nem transforma colunas existentes com segurança.

Checklist para levar o CRUD à produção

Antes de publicar, revise limites de tamanho, paginação, índices, constraints, rollback, autorização e campos expostos. Registre operações relevantes sem incluir conteúdo sensível. Acompanhe latência e volume de consultas, faça backup e ensaie restauração. SQLModel reduz código repetitivo, mas a confiabilidade continua vindo de contratos claros e regras garantidas no banco.

Também defina onde as regras de negócio vivem. Validações de formato pertencem aos modelos de entrada, enquanto regras que dependem do estado atual, como impedir a desativação de um produto presente em uma promoção, precisam consultar o banco dentro do serviço. Não esconda esse tipo de decisão em eventos automáticos difíceis de rastrear.

Ao evoluir a API, preserve compatibilidade quando clientes existentes ainda dependem de um campo. Adicionar uma coluna opcional ao banco, migrar os dados e só depois torná-la obrigatória costuma ser mais seguro do que executar tudo em uma implantação. Meça consultas lentas com dados próximos do volume real; uma tabela vazia não revela problemas de plano, ordenação ou paginação.

Por fim, mantenha funções pequenas o suficiente para testar, mas evite uma camada abstrata genérica que apague diferenças importantes entre entidades. Um produto, um pagamento e uma conta de usuário podem ter operações chamadas “atualizar”, porém exigem regras de autorização, auditoria e concorrência muito diferentes.