Arquivos JSON e CSV funcionam bem para dados simples, mas quando o volume cresce, as consultas ficam complexas ou múltiplos processos precisam acessar os mesmos dados simultaneamente, um banco de dados relacional é a solução adequada. Python oferece suporte nativo ao SQLite e integração elegante com bancos maiores através do SQLAlchemy — o ORM mais usado no ecossistema Python.
SQLite: Banco de Dados Embutido
SQLite é um banco de dados relacional que armazena tudo em um único arquivo. Não requer instalação de servidor — ideal para desenvolvimento, testes e aplicações de pequeno porte.
import sqlite3
# Conectando — cria o arquivo se não existir
conn = sqlite3.connect("escola.db")
# Em memória — útil para testes
conn_mem = sqlite3.connect(":memory:")
# Cursor — executa comandos SQL
cursor = conn.cursor()
# Criando tabela
cursor.execute("""
CREATE TABLE IF NOT EXISTS alunos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
nota REAL DEFAULT 0.0,
ativo INTEGER DEFAULT 1,
criado_em TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
conn.close()
CRUD com sqlite3
import sqlite3
from contextlib import contextmanager
@contextmanager
def get_conn(db_path="escola.db"):
"""Gerenciador de contexto para conexões SQLite."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row # resultados como dicionários
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# CREATE — inserindo dados
def inserir_aluno(nome, email, nota):
with get_conn() as conn:
conn.execute(
"INSERT INTO alunos (nome, email, nota) VALUES (?, ?, ?)",
(nome, email, nota)
)
# READ — consultando dados
def buscar_alunos(nota_minima=0.0):
with get_conn() as conn:
cursor = conn.execute(
"SELECT * FROM alunos WHERE nota >= ? ORDER BY nota DESC",
(nota_minima,)
)
return [dict(row) for row in cursor.fetchall()]
def buscar_por_id(aluno_id):
with get_conn() as conn:
cursor = conn.execute(
"SELECT * FROM alunos WHERE id = ?",
(aluno_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
# UPDATE — atualizando dados
def atualizar_nota(aluno_id, nova_nota):
with get_conn() as conn:
conn.execute(
"UPDATE alunos SET nota = ? WHERE id = ?",
(nova_nota, aluno_id)
)
# DELETE lógico — desativa em vez de apagar
def desativar_aluno(aluno_id):
with get_conn() as conn:
conn.execute(
"UPDATE alunos SET ativo = 0 WHERE id = ?",
(aluno_id,)
)
# Populando o banco
inserir_aluno("Ana Silva", "ana@email.com", 9.5)
inserir_aluno("Bruno Costa", "bruno@email.com", 7.0)
inserir_aluno("Carla Souza", "carla@email.com", 8.5)
inserir_aluno("Diego Lima", "diego@email.com", 5.5)
print("Alunos com nota >= 7.0:")
for aluno in buscar_alunos(7.0):
print(f" {aluno['nome']:15} — {aluno['nota']}")
Sempre use parâmetros (?) em vez de f-strings para montar SQL — isso previne SQL Injection.
Duas observações sobre o código acima. A primeira: o get_conn existe porque o with da própria conexão do sqlite3 não fecha a conexão — ele só faz commit na saída normal e rollback na exceção, e a conexão continua aberta depois do bloco. Quem troca o gerenciador por with sqlite3.connect(...) as conn acha que ganhou o fechamento e não ganhou. A segunda: execute o script duas vezes e a segunda para em sqlite3.IntegrityError: UNIQUE constraint failed: alunos.email. É a restrição UNIQUE fazendo o trabalho dela; para uma carga que pode rodar de novo, o SQLite aceita INSERT ... ON CONFLICT(email) DO NOTHING, que ignora a linha repetida.
Consultas Avançadas
def estatisticas_turma():
with get_conn() as conn:
cursor = conn.execute("""
SELECT
COUNT(*) AS total,
AVG(nota) AS media,
MAX(nota) AS maior,
MIN(nota) AS menor,
SUM(CASE WHEN nota >= 6 THEN 1 ELSE 0 END) AS aprovados
FROM alunos
WHERE ativo = 1
""")
return dict(cursor.fetchone())
def alunos_por_faixa():
with get_conn() as conn:
cursor = conn.execute("""
SELECT
CASE
WHEN nota >= 9 THEN 'Excelente'
WHEN nota >= 7 THEN 'Bom'
WHEN nota >= 6 THEN 'Regular'
ELSE 'Insuficiente'
END AS conceito,
COUNT(*) AS quantidade
FROM alunos
WHERE ativo = 1
GROUP BY conceito
ORDER BY MIN(nota) DESC
""")
return [dict(row) for row in cursor.fetchall()]
stats = estatisticas_turma()
print(f"\nEstatísticas:")
print(f" Total: {stats['total']}")
print(f" Média: {stats['media']:.2f}")
print(f" Aprovados: {stats['aprovados']}")
print("\nDistribuição por conceito:")
for faixa in alunos_por_faixa():
print(f" {faixa['conceito']:15}: {faixa['quantidade']}")
SQLAlchemy: ORM Moderno
SQLAlchemy permite trabalhar com bancos de dados usando classes Python em vez de SQL bruto. A versão 2.0 trouxe uma API mais limpa e moderna:
pip install sqlalchemy
from sqlalchemy import (
create_engine, Column, Integer, String,
Float, Boolean, DateTime, ForeignKey, text
)
from sqlalchemy.orm import DeclarativeBase, relationship, Session
from datetime import datetime, UTC
# Engine — conexão com o banco
engine = create_engine(
"sqlite:///escola_orm.db",
echo=False # echo=True mostra SQL gerado — útil para debug
)
# Base para os modelos
class Base(DeclarativeBase):
pass
# Modelos
class Turma(Base):
__tablename__ = "turmas"
id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String(50), nullable=False, unique=True)
ano = Column(Integer, nullable=False)
alunos = relationship("Aluno", back_populates="turma")
def __repr__(self):
return f"Turma(id={self.id}, nome='{self.nome}')"
class Aluno(Base):
__tablename__ = "alunos"
id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String(100), nullable=False)
email = Column(String(100), unique=True, nullable=False)
nota = Column(Float, default=0.0)
ativo = Column(Boolean, default=True)
criado_em = Column(DateTime, default=lambda: datetime.now(UTC))
turma_id = Column(Integer, ForeignKey("turmas.id"))
turma = relationship("Turma", back_populates="alunos")
@property
def aprovado(self):
return self.nota >= 6.0
def __repr__(self):
return f"Aluno(id={self.id}, nome='{self.nome}', nota={self.nota})"
# Criando as tabelas
Base.metadata.create_all(engine)
O default de criado_em recebe uma função, e não um valor, para ser chamada a cada inserção — com default=datetime.now(UTC), todas as linhas teriam a hora em que o módulo foi importado. O datetime.utcnow que se vê em muito material está obsoleto desde o Python 3.12 e emite um DeprecationWarning a cada linha inserida.
Dois avisos sobre o SQLite por trás do ORM. Ele não verifica chave estrangeira por padrão: o ForeignKey("turmas.id") cria a restrição no esquema, mas um aluno com turma_id=999 é gravado sem erro até que cada conexão execute PRAGMA foreign_keys=ON — com SQLAlchemy, num event.listens_for(engine, "connect"). E os modelos acima usam Column, que continua funcionando na versão 2.0, mas não é o estilo que a documentação atual ensina: nela, cada coluna é nome: Mapped[str] = mapped_column(String(100)), e a anotação de tipo passa a informar o nullable e a alimentar o verificador de tipos.
CRUD com SQLAlchemy 2.0
from sqlalchemy.orm import Session
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
# CREATE
def criar_dados_iniciais():
with Session(engine) as session:
turma_a = Turma(nome="Turma A", ano=2024)
turma_b = Turma(nome="Turma B", ano=2024)
session.add_all([turma_a, turma_b])
session.flush() # obtém IDs sem commitar
alunos = [
Aluno(nome="Ana Silva", email="ana@email.com", nota=9.5, turma=turma_a),
Aluno(nome="Bruno Costa", email="bruno@email.com", nota=7.0, turma=turma_a),
Aluno(nome="Carla Souza", email="carla@email.com", nota=8.5, turma=turma_b),
Aluno(nome="Diego Lima", email="diego@email.com", nota=5.5, turma=turma_b),
]
session.add_all(alunos)
session.commit()
print("Dados criados com sucesso.")
# READ
def listar_alunos_aprovados():
with Session(engine) as session:
stmt = (
select(Aluno)
.options(selectinload(Aluno.turma)) # carrega a turma antes de fechar a sessão
.where(Aluno.nota >= 6.0)
.where(Aluno.ativo == True)
.order_by(Aluno.nota.desc())
)
return session.scalars(stmt).all()
def buscar_com_turma(turma_nome: str):
with Session(engine) as session:
stmt = (
select(Aluno)
.join(Turma)
.where(Turma.nome == turma_nome)
.order_by(Aluno.nome)
)
return session.scalars(stmt).all()
# UPDATE
def atualizar_nota(email: str, nova_nota: float):
with Session(engine) as session:
stmt = (
update(Aluno)
.where(Aluno.email == email)
.values(nota=nova_nota)
)
session.execute(stmt)
session.commit()
# DELETE (lógico)
def desativar_aluno(email: str):
with Session(engine) as session:
stmt = (
update(Aluno)
.where(Aluno.email == email)
.values(ativo=False)
)
session.execute(stmt)
session.commit()
criar_dados_iniciais()
print("\nAlunos aprovados:")
for aluno in listar_alunos_aprovados():
status = "✓" if aluno.aprovado else "✗"
turma = aluno.turma.nome if aluno.turma else "—"
print(f" {status} {aluno.nome:15} — {aluno.nota} ({turma})")
print("\nAlunos da Turma A:")
for aluno in buscar_com_turma("Turma A"):
print(f" {aluno.nome}")
O selectinload em listar_alunos_aprovados não é enfeite. Sem ele, o laço final levanta DetachedInstanceError: Parent instance <Aluno> is not bound to a Session; lazy load operation of attribute 'turma' cannot proceed. Um relationship é carregado sob demanda por padrão: a turma só é buscada no banco quando alguém lê aluno.turma, e a função devolve os alunos depois que o with Session já fechou a sessão. O selectinload busca as turmas numa segunda consulta, ainda dentro da sessão. É também por isso que o salvar do repositório adiante chama session.refresh: o commit expira os atributos do objeto, e sem recarregá-los ali o aluno devolvido não pode mais ser lido.
Migrações com Alembic
Para projetos reais, use Alembic para gerenciar mudanças no esquema do banco:
pip install alembic
alembic init migrations
# migrations/env.py — configuração básica
from sqlalchemy import engine_from_config
from alembic import context
from meus_modelos import Base
target_metadata = Base.metadata
# Gerando uma migração automaticamente
alembic revision --autogenerate -m "adicionar coluna telefone em alunos"
# Aplicando migrações
alembic upgrade head
# Revertendo
alembic downgrade -1
Exemplo Completo: Repositório com SQLAlchemy
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session
from typing import List, Optional
class AlunoRepositorio:
"""Encapsula todas as operações de banco para Aluno."""
def __init__(self, engine):
self._engine = engine
def salvar(self, aluno: Aluno) -> Aluno:
with Session(self._engine) as session:
session.add(aluno)
session.commit()
session.refresh(aluno)
return aluno
def buscar_por_id(self, aluno_id: int) -> Optional[Aluno]:
with Session(self._engine) as session:
return session.get(Aluno, aluno_id)
def buscar_por_email(self, email: str) -> Optional[Aluno]:
with Session(self._engine) as session:
stmt = select(Aluno).where(Aluno.email == email)
return session.scalars(stmt).first()
def listar_todos(self, apenas_ativos: bool = True) -> List[Aluno]:
with Session(self._engine) as session:
stmt = select(Aluno)
if apenas_ativos:
stmt = stmt.where(Aluno.ativo == True)
return session.scalars(stmt).all()
def media_notas(self, turma_id: int = None) -> float:
with Session(self._engine) as session:
stmt = select(func.avg(Aluno.nota)).where(Aluno.ativo == True)
if turma_id:
stmt = stmt.where(Aluno.turma_id == turma_id)
resultado = session.scalar(stmt)
return round(resultado or 0.0, 2)
def top_alunos(self, n: int = 3) -> List[Aluno]:
with Session(self._engine) as session:
stmt = (
select(Aluno)
.where(Aluno.ativo == True)
.order_by(Aluno.nota.desc())
.limit(n)
)
return session.scalars(stmt).all()
repo = AlunoRepositorio(engine)
print(f"\nMédia geral: {repo.media_notas()}")
print("\nTop 3 alunos:")
for aluno in repo.top_alunos(3):
print(f" {aluno.nome:15} — {aluno.nota}")
O SQLite resolve a maior parte do que se pede a um banco em desenvolvimento, testes e aplicações pequenas: um arquivo, nenhum servidor, SQL de verdade. O sqlite3 da biblioteca padrão é suficiente para usá-lo bem, desde que a conexão seja fechada por alguém — o with dela só cuida da transação — e que todo valor entre na consulta como parâmetro, nunca por f-string. Vale lembrar também que o SQLite é permissivo em pontos onde outros bancos são rígidos: chave estrangeira só é verificada com PRAGMA foreign_keys=ON, e fuso horário gravado numa coluna de data não volta na leitura.
O SQLAlchemy troca o SQL escrito à mão por consultas montadas com select() e por classes mapeadas para tabelas, e com isso o código passa a mudar de banco trocando a URL do engine. O preço é entender a Session. Um relacionamento é carregado sob demanda, e lê-lo depois que a sessão fechou levanta DetachedInstanceError; lê-lo dentro de um laço, com a sessão aberta, faz uma consulta por item, e o selectinload resolve os dois problemas. O commit expira os atributos, o que explica o refresh do repositório. E o esquema, quando o projeto for real, evolui por migrações do Alembic, revisadas antes de aplicar — o --autogenerate sugere, não decide.
Fontes e leituras recomendadas
- sqlite3 — documentação oficial — https://docs.python.org/3/library/sqlite3.html
- SQLAlchemy ORM — tutorial oficial — https://docs.sqlalchemy.org/en/20/orm/quickstart.html
- SQLAlchemy 2.0 — guia de migração — https://docs.sqlalchemy.org/en/20/changelog/migration_20.html
- Alembic — migrações de banco — https://alembic.sqlalchemy.org/en/latest/
- SQL Injection — OWASP — https://owasp.org/www-community/attacks/SQL_Injection
- MYERS, Jason; COPELAND, Rick. Essential SQLAlchemy. 2. ed. O'Reilly Media, 2016. — guia de referência do SQLAlchemy, cobrindo Core e ORM.
- PERCIVAL, Harry; GREGORY, Bob. Architecture Patterns with Python. O'Reilly Media, 2020. Cap. 2 — padrão Repository com SQLAlchemy em projetos reais.
Exercícios
Exercício 1
Um relatório permite ao usuário escolher a coluna de ordenação num menu, e o desenvolvedor, seguindo a regra de nunca montar SQL com f-string, escreveu conn.execute("SELECT nome FROM alunos ORDER BY ? DESC", (coluna,)). O código roda sem erro, mas a lista aparece sempre na mesma ordem, qualquer que seja a coluna escolhida. Explique e corrija sem abrir espaço para SQL Injection.
Ver resposta
✓ Resposta: O parâmetro ? substitui um valor, nunca um nome de coluna, de tabela ou uma palavra-chave. O banco recebe a consulta como ORDER BY 'nota' DESC, ordenando por uma string constante, igual em todas as linhas — e ordenar por um valor igual em todas as linhas não muda nada, então as linhas saem na ordem em que estão armazenadas. Medido com Ana 9.5, Bruno 7.0 e Carla 8.5: ORDER BY ? com "nota" devolve Ana, Bruno, Carla, a ordem de inserção; ORDER BY nota DESC devolve Ana, Carla, Bruno. Não há erro porque a consulta é válida, só não faz o que se pretendia. A tentação seguinte, voltar à f-string, reabre a injeção: com f"... WHERE nome = '{nome}'" e o valor x' OR '1'='1, a consulta devolve todos os alunos, enquanto a versão com parâmetro devolve lista vazia. A saída correta para identificadores é uma lista de valores permitidos, mantida no código: COLUNAS = {"nome": "nome", "nota": "nota", "criacao": "criado_em"}, depois coluna_sql = COLUNAS[escolha], que levanta KeyError para qualquer outra coisa, e só então a interpolação, que passa a ser segura porque o texto vem do dicionário, não do usuário. A mesma regra vale para a direção (ASC/DESC) e para nomes de tabela. No SQLAlchemy, o equivalente é escolher o atributo da classe a partir do mesmo dicionário, como em order_by(getattr(Aluno, coluna).desc()) precedido da verificação.
Exercício 2
Uma página lista as 200 turmas da escola com a quantidade de alunos de cada uma: for t in session.scalars(select(Turma)).all(): print(t.nome, len(t.alunos)). Com SQLite, na máquina do desenvolvedor, a página abre instantaneamente. Em produção, com o banco num servidor separado, ela leva vários segundos. Explique a diferença e proponha duas correções, dizendo quando usar cada uma.
Ver resposta
✓ Resposta: É o problema N+1. O relationship é carregado sob demanda: a primeira consulta traz as 200 turmas, e cada t.alunos dispara outra consulta para buscar os alunos daquela turma. Medido com um contador de eventos do SQLAlchemy, o laço faz 201 consultas; com select(Turma).options(selectinload(Turma.alunos)), faz 2, a das turmas e uma única com WHERE turma_id IN (...) para todos os alunos. No SQLite local a diferença foi de 0,060 s para 0,012 s, cinco vezes, e passa despercebida porque cada consulta é uma chamada de função dentro do mesmo processo. Com um servidor na rede, cada consulta paga ida e volta: a 2 ms por viagem, 201 consultas custam 400 ms só de latência, e o número cresce com as turmas. É por isso que o defeito só aparece em produção. Uma curiosidade da medição: no sentido inverso, lendo aluno.turma para cem alunos de cinco turmas, o lazy load fez só 6 consultas, porque a sessão reaproveita a turma já carregada. O N+1 pesa mesmo nas coleções. A primeira correção é o selectinload, quando os objetos relacionados vão ser usados. A segunda, melhor aqui, é não carregar alunos para contá-los: select(Turma.nome, func.count(Aluno.id)).outerjoin(Turma.alunos).group_by(Turma.id) faz uma consulta só e devolve números, sem montar mil objetos. Para detectar o problema cedo, echo=True no engine mostra as consultas, e configurar lazy="raise" no relacionamento faz qualquer carga implícita virar erro.
Exercício 3
Um modelo grava criado_em com mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)), seguindo a recomendação de usar datas aware. Os testes rodam em SQLite. Um teste compara o valor lido do banco com datetime.now(UTC) - timedelta(minutes=1) e falha com TypeError: can't compare offset-naive and offset-aware datetimes. O desenvolvedor jura que gravou com fuso. Explique.
Ver resposta
✓ Resposta: Ele gravou com fuso, e o fuso se perdeu no caminho. O SQLite não tem tipo de data: guarda datas como texto ou número, e o dialeto do SQLAlchemy para SQLite grava a data sem o deslocamento e lê de volta um datetime naive, mesmo com timezone=True no modelo. Medido: o valor lido tem tzinfo igual a None. A comparação do teste junta um naive com um aware, e o Python se recusa a supor em que fuso o naive está. O mesmo código em PostgreSQL, cujo TIMESTAMP WITH TIME ZONE guarda o instante, devolveria um valor aware e passaria — o que torna o problema mais traiçoeiro: o teste em SQLite falha por um motivo que produção não tem, ou, pior, alguém "corrige" o teste retirando o fuso e passa a testar uma coisa diferente do que roda em produção. Há duas saídas honestas. Uma é normalizar na leitura: como o que foi gravado é UTC, valor.replace(tzinfo=UTC) reconstrói o instante, e um TypeDecorator do SQLAlchemy pode fazer isso em toda coluna de data, para qualquer banco. A outra é rodar os testes no mesmo banco de produção, num contêiner, o que elimina essa e outras diferenças de comportamento — o SQLite também não verifica chave estrangeira sem PRAGMA foreign_keys=ON, por exemplo. O SQLite como banco de testes é conveniente justamente até o dia em que a diferença de semântica passa a ser o bug.
Exercício 4
Um painel administrativo apaga turmas com session.delete(turma); session.commit(). A turma A tinha 20 alunos. Depois da exclusão, os alunos continuam no banco, mas somem de todos os relatórios por turma, e ninguém recebeu mensagem de erro. O modelo é o deste artigo, com ForeignKey("turmas.id") em Aluno.turma_id. O que aconteceu com os alunos, e que alternativas o modelo deveria considerar?
Ver resposta
✓ Resposta: Ao apagar um objeto que tem uma coleção relacionada, o SQLAlchemy, com a configuração padrão do relationship, desassocia os filhos antes de apagar o pai: emite UPDATE alunos SET turma_id = NULL para os alunos da turma, num lote só, e depois o DELETE da turma. Medido no modelo do artigo, os 20 alunos continuaram no banco, todos com turma_id nulo, num total de 100. Como a coluna aceita nulo, nada impede a operação, e o resultado são 20 alunos órfãos que nenhum relatório por turma encontra. Não há uma resposta certa universal, e sim uma decisão que o modelo precisa tomar de forma explícita. Se aluno não pode existir sem turma, a coluna deve ser nullable=False, e aí a exclusão falha com IntegrityError em vez de produzir órfãos. Se apagar a turma deve apagar os alunos, isso se declara com cascade="all, delete-orphan" no relacionamento — decisão rara para dados de pessoas, e perigosa. Na maioria dos sistemas, a resposta é impedir: ForeignKey("turmas.id", ondelete="RESTRICT") e passive_deletes="all" no relacionamento, para que o banco recuse apagar turma com alunos e alguém tenha de transferi-los primeiro. E, como no próprio artigo, trocar a exclusão física por uma lógica, com uma coluna ativa, evita o problema na origem. No SQLite, qualquer uma dessas regras no banco só vale com PRAGMA foreign_keys=ON.
Exercício 5
No início do semestre, antes de qualquer matrícula, a tela de estatísticas da turma, que usa a função estatisticas_turma() deste artigo, derruba o sistema com TypeError: unsupported format string passed to NoneType.__format__. No semestre anterior, com alunos cadastrados, ela funcionou sempre. Explique, corrija e diga o que o caso ensina sobre funções de agregação em SQL.
Ver resposta
✓ Resposta: Sem nenhuma linha que satisfaça o WHERE ativo = 1, a consulta devolve uma linha só, com COUNT(*) igual a 0 e as demais agregações — AVG, MAX, MIN e o SUM do CASE — iguais a NULL, que chega ao Python como None. Medido: (0, None). O COUNT é o único que devolve zero em conjunto vazio, porque conta; os outros não têm valor para calcular, e o padrão SQL manda devolver nulo. A f-string com {stats['media']:.2f} tenta formatar None como número e levanta o TypeError. No semestre anterior o conjunto nunca esteve vazio, então o caminho nunca foi exercitado. Há duas correções complementares. No SQL, COALESCE(AVG(nota), 0) transforma o nulo em zero, mas isso só é certo quando zero tem significado: uma média 0,00 numa turma sem alunos é uma informação falsa, e o SUM de aprovados virando 0 é aceitável, enquanto a média virando 0 não é. No Python, a apresentação deve tratar a ausência: f"{media:.2f}" if media is not None else "—". O repositório do artigo faz algo parecido em media_notas, com round(resultado or 0.0, 2), e herda a mesma ambiguidade entre "não há alunos" e "a média é zero". A lição geral é que toda agregação sobre um filtro precisa de um teste com o conjunto vazio, porque é o único caso em que o tipo do resultado muda.