Banco de Dados com SQLite e SQLAlchemy

Banco de Dados com SQLite e SQLAlchemy

SQLite com sqlite3 e SQLAlchemy 2.0 em Python, do CRUD ao repositório e às migrações com Alembic. Com o que costuma falhar em silêncio: o lazy load que quebra fora da sessão ou vira 201 consultas, a chave estrangeira que o SQLite não verifica e o ORDER BY ? que não ordena nada.
Python

• • 19 min de leitura

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

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.

Comentários

Mais em Python

Funções: definição, parâmetros e escopo
Funções: definição, parâmetros e escopo

Nomear um bloco de código é o primeiro passo para escrever programas que…

Tuplas e Sets: imutabilidade e unicidade
Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos…

FastAPI: APIs modernas com tipagem e documentação automática
FastAPI: APIs modernas com tipagem e documentação automática

O FastAPI valida, converte e documenta a partir dos tipos, e os erros moram no…