CRUD em Python com SQLite — passo a passo para iniciantes

Vamos construir um mini-app de tarefas (to-do) no terminal usando Python + SQLite. A ideia é você entender cada parte do código, do zero: conectar, criar tabela, inserir, listar, atualizar e excluir (CRUD). Então, no caminho, explicarei decisões de projeto, boas práticas e como ler erros comuns.

0) Pré-requisitos mínimos

Antes de tudo, você precisa ter o Python 3 instalado (o módulo padrão sqlite3 já vem junto). Então, para saber abrir um terminal e executar python --version (ou python3 --version em alguns sistemas).

1) O que é SQLite (sem mistério)

  • É um banco de dados que vive em um único arquivo (ex.: tarefas.sqlite).
  • Contudo, não precisa de servidor rodando.
  • Além disso, ele é excelente para projetos pequenos, scripts, estudos e protótipos.

Para nosso projeto: tudo que cadastrarmos (tarefas) ficará dentro de um arquivo .sqlite. Por isso, se você apagar esse arquivo, apaga os dados.

Observação: SQLite é “serverless”, mas ainda assim é um banco relacional com SQL “de verdade”, constraints, transações e índices.

2) Comece pelo começo: conectando e criando a tabela

A princípio, crie uma pasta, por exemplo crud_sqlite, e depois, dentro dela crie passo1.py:

# passo1.py
import sqlite3
from pathlib import Path

DB_PATH = Path("tarefas.sqlite")

SQL_CRIAR_TABELA = """
CREATE TABLE IF NOT EXISTS tarefas (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo TEXT NOT NULL,
    descricao TEXT,
    status TEXT NOT NULL DEFAULT 'aberta',
    criado_em TEXT NOT NULL,
    atualizado_em TEXT
);
"""

# 1) abre (ou cria) o arquivo do banco
con = sqlite3.connect(DB_PATH)

# 2) cria um "cursor" para enviar comandos SQL
cur = con.cursor()

# 3) cria a tabela se não existir
cur.execute(SQL_CRIAR_TABELA)

# 4) salva a alteração (commit) e fecha
con.commit()
con.close()

print("Banco inicializado: tarefas.sqlite e tabela criada (se não existia).")

Entenda cada linha:

  • sqlite3.connect(DB_PATH): abre o arquivo do banco (ou cria se não existir).
  • cursor(): objeto que envia comandos SQL.
  • CREATE TABLE IF NOT EXISTS: evita recriar a tabela sempre.
  • commit(): confirma alterações (DDL/INSERT/UPDATE/DELETE).
  • close(): fecha a conexão corretamente.

Rode:

python passo1.py

Se não deu erro, o arquivo tarefas.sqlite apareceu ao lado do script.

3) Inserindo dados (Create)

Vamos inserir uma tarefa. Então, crie passo2.py:

# passo2.py
import sqlite3
from pathlib import Path
from datetime import datetime

DB_PATH = Path("tarefas.sqlite")

con = sqlite3.connect(DB_PATH)
cur = con.cursor()

# Usamos placeholders (?) para evitar SQL Injection e converter tipos
sql = "INSERT INTO tarefas (titulo, descricao, status, criado_em) VALUES (?, ?, ?, ?)"
dados = (
    "Estudar Python",
    "Ler sobre CRUD e SQLite",
    "aberta",
    datetime.now().isoformat(timespec="seconds")
)

cur.execute(sql, dados)
con.commit()

print("Inserido! ID gerado:", cur.lastrowid)

con.close()

Por que desse jeito?

  • Placeholders ?: nunca concatene valores do usuário na SQL; use parâmetros.
  • cur.lastrowid: pega o ID gerado automaticamente.
  • datetime.now().isoformat(...): padrão legível e consistente (ISO-8601).

Rode:

python passo2.py

4) Consultando dados (Read)

Agora, vamos listar o que está no banco. Para isso, crie passo3.py:

# passo3.py
import sqlite3
from pathlib import Path

DB_PATH = Path("tarefas.sqlite")

# Dica: row_factory permite ler por nome da coluna
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
cur = con.cursor()

cur.execute("SELECT * FROM tarefas ORDER BY id")
linhas = cur.fetchall()

if not linhas:
    print("Não há tarefas.")
else:
    for linha in linhas:
        # linha é um "Row", dá pra usar dict(linha)
        t = dict(linha)
        print(f"[{t['id']}] {t['titulo']} | {t['status']} (criado: {t['criado_em']})")

con.close()

Aprendizados:

  • con.row_factory = sqlite3.Row: permite acessar colunas por nome.
  • fetchall(): busca todas as linhas.
  • ORDER BY id: mantém a listagem previsível.

pronto, então rode:

python passo3.py

5) Atualizando (Update)

Precisamos de meio para atualizar nossos registros. Por isso, crie agora passo4.py para alterar título/descrição/status de uma tarefa pelo ID:

# passo4.py
import sqlite3
from pathlib import Path
from datetime import datetime

DB_PATH = Path("tarefas.sqlite")

def atualizar_tarefa(tarefa_id, titulo=None, descricao=None, status=None):
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()

    campos = []
    valores = []

    if titulo is not None:
        campos.append("titulo = ?")
        valores.append(titulo.strip())
    if descricao is not None:
        campos.append("descricao = ?")
        valores.append(descricao.strip())
    if status is not None:
        campos.append("status = ?")
        valores.append(status)

    if not campos:
        print("Nada para atualizar.")
        con.close()
        return False

    campos.append("atualizado_em = ?")
    valores.append(datetime.now().isoformat(timespec="seconds"))

    valores.append(tarefa_id)

    sql = f"UPDATE tarefas SET {', '.join(campos)} WHERE id = ?"
    cur.execute(sql, valores)
    con.commit()

    alteradas = cur.rowcount
    con.close()
    return alteradas > 0

if __name__ == "__main__":
    ok = atualizar_tarefa(
        tarefa_id=1,
        status="concluida",
        descricao="Terminei o estudo de CRUD!"
    )
    print("Atualizada." if ok else "Nada foi alterado (ID existe?).")

Estratégia usada:

  • Montamos campos apenas com o que o usuário quer mudar.
  • Usamos WHERE id = ? para alterar um registro.
  • rowcount diz quantas linhas foram afetadas.

Para testar, rode:

python passo4.py

Mas, depois liste de novo com passo3.py.

6) Excluindo (Delete)

Agora, crie passo5.py:

# passo5.py
import sqlite3
from pathlib import Path

DB_PATH = Path("tarefas.sqlite")

def excluir_tarefa(tarefa_id):
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()
    cur.execute("DELETE FROM tarefas WHERE id = ?", (tarefa_id,))
    con.commit()
    removidas = cur.rowcount
    con.close()
    return removidas > 0

if __name__ == "__main__":
    ok = excluir_tarefa(1)
    print("Excluída." if ok else "Não encontrada.")

Agora, rode:

python passo5.py

Então, para finalizar essa etapa, confira o resultado desse teste com passo3.py.

7) Juntando tudo em um mini-app (um arquivo só)

Visto que, você já entendeu cada operação separada, então, vamos então consolidar tudo em um só arquivo com funções CRUD e um menu de terminal.

Primeiro, crie o arquivo app.py:

import sqlite3
from pathlib import Path
from datetime import datetime

DB_PATH = Path("tarefas.sqlite")

SQL_CRIAR_TABELA = """
CREATE TABLE IF NOT EXISTS tarefas (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo TEXT NOT NULL,
    descricao TEXT,
    status TEXT NOT NULL DEFAULT 'aberta' CHECK (status IN ('aberta','fazendo','concluida')),
    criado_em TEXT NOT NULL,
    atualizado_em TEXT
);
"""

def obter_conexao():
    con = sqlite3.connect(DB_PATH)
    con.row_factory = sqlite3.Row
    return con

def inicializar_banco():
    with obter_conexao() as con:
        con.execute(SQL_CRIAR_TABELA)
        con.commit()

# ---------------- CRUD ----------------
def criar_tarefa(titulo, descricao="", status="aberta"):
    titulo = (titulo or "").strip()
    if not titulo:
        raise ValueError("Título é obrigatório.")
    if status not in ("aberta", "fazendo", "concluida"):
        raise ValueError("Status inválido.")
    agora = datetime.now().isoformat(timespec="seconds")
    with obter_conexao() as con:
        cur = con.execute(
            "INSERT INTO tarefas (titulo, descricao, status, criado_em) VALUES (?, ?, ?, ?)",
            (titulo, (descricao or "").strip(), status, agora),
        )
        con.commit()
        return cur.lastrowid

def listar_tarefas(status=None):
    with obter_conexao() as con:
        if status:
            cur = con.execute(
                "SELECT * FROM tarefas WHERE status = ? ORDER BY id", (status,))
        else:
            cur = con.execute("SELECT * FROM tarefas ORDER BY id")
        return [dict(l) for l in cur.fetchall()]

def obter_tarefa_por_id(tarefa_id: int):
    with obter_conexao() as con:
        cur = con.execute("SELECT * FROM tarefas WHERE id = ?", (tarefa_id,))
        row = cur.fetchone()
        return dict(row) if row else None

def atualizar_tarefa(tarefa_id: int, *, titulo=None, descricao=None, status=None):
    campos, valores = [], []
    if titulo is not None:
        t = titulo.strip()
        if not t:
            raise ValueError("Título não pode ser vazio.")
        campos.append("titulo = ?")
        valores.append(t)
    if descricao is not None:
        campos.append("descricao = ?")
        valores.append(descricao.strip())
    if status is not None:
        if status not in ("aberta", "fazendo", "concluida"):
            raise ValueError("Status inválido.")
        campos.append("status = ?")
        valores.append(status)

    if not campos:
        return False

    campos.append("atualizado_em = ?")
    valores.append(datetime.now().isoformat(timespec="seconds"))
    valores.append(tarefa_id)

    sql = f"UPDATE tarefas SET {', '.join(campos)} WHERE id = ?"

    with obter_conexao() as con:
        cur = con.execute(sql, valores)
        con.commit()
        return cur.rowcount > 0

def excluir_tarefa(tarefa_id: int):
    with obter_conexao() as con:
        cur = con.execute("DELETE FROM tarefas WHERE id = ?", (tarefa_id,))
        con.commit()
        return cur.rowcount > 0

# ---------------- CLI ----------------
def mostrar_tarefa(t):
    print("— Detalhes da tarefa —")
    for k in ("id", "titulo", "descricao", "status", "criado_em", "atualizado_em"):
        print(f"{k}: {t.get(k)}")

def menu():
    print("\n=== Tarefas (SQLite) ===")
    print("1. Criar tarefa")
    print("2. Listar tarefas (opcional: filtrar por status)")
    print("3. Ver tarefa por ID")
    print("4. Atualizar tarefa")
    print("5. Excluir tarefa")
    print("0. Sair")
    return input("Escolha: ").strip()

def main():
    inicializar_banco()
    while True:
        opc = menu()
        try:
            if opc == "1":
                titulo = input("Título: ")
                descricao = input("Descrição (opcional): ")
                status = input("Status [aberta/fazendo/concluida] (Enter=aberta): ").strip() or "aberta"
                novo_id = criar_tarefa(titulo, descricao, status)
                print(f"Tarefa criada com ID {novo_id}.")
            elif opc == "2":
                filtro = input("Filtrar por status (Enter = todos): ").strip() or None
                tarefas = listar_tarefas(filtro)
                if not tarefas:
                    print("Sem tarefas.")
                else:
                    for t in tarefas:
                        print(f"[{t['id']}] {t['titulo']} | {t['status']} (criado: {t['criado_em']})")
            elif opc == "3":
                tid = int(input("ID: "))
                t = obter_tarefa_por_id(tid)
                if t:
                    mostrar_tarefa(t)
                else:
                    print("Não encontrada.")
            elif opc == "4":
                tid = int(input("ID: "))
                novo_titulo = input("Novo título (Enter = manter): ").strip()
                nova_desc = input("Nova descrição (Enter = manter): ").strip()
                novo_status = input("Novo status [aberta/fazendo/concluida] (Enter = manter): ").strip()
                ok = atualizar_tarefa(
                    tid,
                    titulo=novo_titulo or None,
                    descricao=nova_desc or None,
                    status=novo_status or None,
                )
                print("Atualizada." if ok else "Nada alterado (ID existe?).")
            elif opc == "5":
                tid = int(input("ID: "))
                ok = excluir_tarefa(tid)
                print("Excluída." if ok else "Não encontrada.")
            elif opc == "0":
                print("Até mais!")
                break
            else:
                print("Opção inválida.")
        except ValueError as e:
            print("Entrada inválida:", e)
        except sqlite3.IntegrityError as e:
            print("Regra do banco violada:", e)

if __name__ == "__main__":
    main()

Finalmente, rode:

python app.py

Mas, por que esse app é mais “robusto”?

  • row_factory = sqlite3.Row: facilita imprimir por nome.
  • Constraint CHECK no status: o banco impede valores fora do conjunto.
  • Além disso, temos Validações no Python (ValueError) antes de gravar.
  • with obter_conexao(): abre/fecha conexão automaticamente; commit() garante persistência.
  • Ainda, temos funções CRUD separadas da camada de CLI (menu), facilitando testes e reaproveitamento depois (ex.: API).

8) O que é transação? Por que commit()?

Uma transação é um conjunto de operações que deve ser executado como uma unidade.

  • Por isso, se tudo deu certo → commit() confirma.
  • Mas, se algo falhou → você pode chamar rollback() (voltar atrás).

Portanto, no nosso fluxo, cada operação crítica é feita dentro de um bloco with e concluída com commit().

Curiosidade: por padrão, sqlite3 trabalha fora do modo autocommit. Logo, não chamar commit() (ou sair do with) implica perder alterações.

9) Tratando erros comuns (e entendendo a causa)

  • sqlite3.OperationalError: no such table: tarefas
    Você esqueceu de rodar inicializar_banco() (ou o passo1.py).
    Solução: crie a tabela antes de inserir/consultar.
  • ValueError: Título é obrigatório.
    O titulo veio vazio.
    Solução: informe um texto não vazio.
  • sqlite3.IntegrityError: CHECK constraint failed
    O status não era aberta, fazendo ou concluida.
    Solução: valide o valor antes de inserir/atualizar.
  • Arquivo .sqlite “sumiu”
    Você rodou o script de outro diretório e gerou o banco em outro lugar.
    Solução: use Path() como no exemplo e rode os scripts na mesma pasta.

10) Extras úteis (opcionais, mas recomendados)

  • Índice para acelerar filtros por status: CREATE INDEX IF NOT EXISTS idx_tarefas_status ON tarefas(status);
  • Pesquisar por termo no título (LIKE): SELECT * FROM tarefas WHERE titulo LIKE '%' || ? || '%';
  • Exportar para JSON: use listar_tarefas() + json.dump(...).
  • Modo WAL (escrita concorrente melhor): PRAGMA journal_mode=WAL; (guarde para depois; em apps simples, o padrão já atende.)

11) Boas práticas desde cedo

  • Primeiro, Nunca monte SQL com concatenação de strings do usuário → use parâmetros ?.
  • Então, Valide na aplicação (Python) e reforce no banco (constraints).
  • Além disso, Feche conexões (context manager with ajuda muito).
  • Ainda, Padronize datas (ISO-8601 facilita debug e integração).
  • Finalmente, separe responsabilidades quando o projeto crescer (conexão, repositório/CRUD, CLI/API).

Exemplo de organização em 3 arquivos (para evoluir):

crud_sqlite/
├─ db.py          # conexão + criação de tabela
├─ repo.py        # funções CRUD (sem I/O de terminal)
└─ cli.py         # menu e interação com o usuário (chama repo)

12) Exercícios guiados (para fixar de verdade)

  1. Marcar tudo como concluído: atualize todas as tarefas com status <> 'concluida', setando status='concluida' e atualizado_em=AGORA. Exiba quantas linhas foram afetadas.
  2. Busca por termo: pergunte um termo e liste tarefas cujo titulo contenha esse termo usando LIKE com parâmetros.
  3. Relatório por status: mostre quantas tarefas há por status (use GROUP BY status).
  4. Exportar CSV/JSON: gere tarefas.csv ou tarefas.json com o resultado de listar_tarefas().

13) Próximo nível (quando se sentir pronto)

  • Criar uma API Web com FastAPI reaproveitando as funções do repositório.
  • Adicionar testes com pytest (cenários: inserir/listar/atualizar/excluir).
  • Migrar de SQL manual para um ORM (SQLAlchemy, Peewee) quando o domínio aumentar.

14) Checklist rápido (para você se guiar)

  • [ ] Conectei no SQLite e criei a tabela.
  • [ ] Inserção com placeholders ? funcionando.
  • [ ] Lista ordenada por id usando row_factory.
  • [ ] Atualização só altera campos informados.
  • [ ] Exclusão por id confirmando rowcount.
  • [ ] Validações básicas (título e status).
  • [ ] Entendi transação, commit() e quando usar.
  • [ ] Rodei o app.py e brinquei no menu.

Veja também (links internos)

Referências (links externos)

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Rolar para cima