Skip to main content


SQLite com Go​


O SQLite é um banco de dados relacional que funciona diretamente dentro da aplicação, sem precisar de um servidor separado. Diferentemente do PostgreSQL e do MySQL, que normalmente executam como serviços independentes, o SQLite trabalha diretamente com um arquivo que armazena as informações do banco.


No Go, podemos trabalhar com SQLite por meio do pacote database/sql e de um driver compatível. O database/sql fornece os métodos necessários para executar consultas e comandos SQL, enquanto o driver é responsável pela comunicação específica com o SQLite.


Neste material, vamos usar o driver modernc.org/sqlite, que implementa a comunicação com SQLite em Go sem depender de CGO. Isso permite trabalhar com o banco usando os recursos do database/sql, mantendo uma estrutura semelhante à que seria usada com PostgreSQL ou MySQL.



Criando um banco SQLite​


Diferentemente de bancos como PostgreSQL e MySQL, o SQLite não exige a criação de um servidor ou de um serviço separado. Normalmente, um banco SQLite é representado por um arquivo, que armazena as tabelas, os registros, os índices e outras estruturas necessárias para o funcionamento do banco.


Imagine que estamos desenvolvendo uma aplicação Go responsável por cadastrar usuários. Podemos organizar nosso projeto da seguinte maneira.

meu-projeto/
main.go
go.mod
dados.db

O arquivo dados.db representa o banco SQLite. A extensão .db é apenas uma convenção, pois o SQLite não exige uma extensão específica para seus arquivos.


Também podemos encontrar bancos com extensões como .sqlite ou .sqlite3. Independentemente da extensão, o conteúdo continua sendo gerenciado pelo SQLite.


Uma característica importante é que não precisamos criar esse arquivo manualmente. Quando a aplicação abre uma conexão com um banco que ainda não existe, o SQLite normalmente cria o arquivo automaticamente, desde que o caminho seja válido e a aplicação tenha permissão para gravar nesse local.


Isso significa que podemos começar com um projeto contendo apenas os arquivos do Go e deixar que o SQLite crie o banco quando a conexão for estabelecida.



Preparando o projeto​


Antes de acessar o banco, precisamos adicionar um driver SQLite ao projeto. Podemos começar criando um diretório e inicializando um módulo Go.

# Cria o diretório do projeto.
mkdir meu-projeto

# Entra no diretório.
cd meu-projeto

# Inicializa o módulo Go.
go mod init exemplo.com/meu-projeto

O comando go mod init cria o arquivo go.mod, responsável por identificar o módulo e registrar as dependências do projeto. Em seguida, instalamos o driver SQLite.

# Adiciona o driver SQLite às dependências.
go get modernc.org/sqlite

O comando go get adiciona a dependência ao módulo. O Go também registra as informações necessárias nos arquivos go.mod e go.sum. Depois disso, podemos importar o driver no código.

import (
"database/sql"

// Registra o driver SQLite.
_ "modernc.org/sqlite"
)

O pacote database/sql faz parte da biblioteca padrão do Go e fornece os métodos de acesso ao banco. Já modernc.org/sqlite é uma dependência externa que implementa a comunicação com SQLite. O caractere _ indica que o pacote está sendo importado apenas pelos efeitos de sua inicialização. Nesse caso, o driver registra sua implementação no database/sql, permitindo que o nome "sqlite" seja reconhecido quando abrirmos o banco.


Criando o arquivo do banco​


Com o driver disponível, podemos criar um programa responsável por abrir o banco SQLite.

package main

import (
"database/sql"
"log"

_ "modernc.org/sqlite"
)

func main() {
// Configura o acesso ao banco SQLite.
db, err := sql.Open("sqlite", "dados.db")
if err != nil {
log.Fatal(err)
}

// Fecha o gerenciador quando a função terminar normalmente.
defer db.Close()

// Estabelece ou verifica uma conexão com o banco.
if err := db.Ping(); err != nil {
log.Println("Erro ao acessar o banco:", err)
return
}

log.Println("Banco SQLite acessível!")
}

O método sql.Open() recebe o nome do driver e uma string com as informações necessárias para acessar o banco.

db, err := sql.Open("sqlite", "dados.db")

O primeiro argumento, "sqlite", representa o nome registrado pelo driver. O segundo, "dados.db", indica o arquivo que será aberto ou criado. O método sql.Open() não garante que uma conexão física seja estabelecida imediatamente. Ele cria um *sql.DB, que representa o gerenciador de conexões. Por isso, usamos db.Ping() para verificar se o banco está acessível.


Quando uma conexão física é aberta, o SQLite pode criar o arquivo dados.db, caso ele ainda não exista. Se executarmos o programa com:

go run .

O arquivo do banco deverá aparecer no diretório do projeto, desde que a conexão seja estabelecida corretamente.



Caminhos relativos e absolutos​


Quando informamos apenas "dados.db", estamos usando um caminho relativo. Isso significa que o arquivo será procurado ou criado em relação ao diretório de trabalho do processo.


Por exemplo, se executarmos a aplicação dentro do diretório meu-projeto, o banco normalmente será criado nesse mesmo diretório.

// Caminho relativo.
db, err := sql.Open("sqlite", "dados.db")

Também podemos informar um caminho absoluto.

// Exemplo de caminho absoluto no Linux.
db, err := sql.Open("sqlite", "/home/usuario/dados.db")

Um caminho absoluto identifica diretamente a localização do arquivo. Isso evita que o banco seja criado em locais diferentes dependendo de onde a aplicação for executada.


É necessário ter cuidado com caminhos relativos porque executar o mesmo programa a partir de outro diretório pode fazer com que ele abra ou crie outro arquivo dados.db.


Banco SQLite em memória​


O SQLite também permite criar bancos que ficam armazenados apenas na memória.

Para isso, podemos informar ":memory:" no lugar do caminho de um arquivo.

// Configura um banco SQLite em memória.
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return err
}

// Mantém uma única conexão para esse banco em memória.
db.SetMaxOpenConns(1)

defer db.Close()

// Abre a conexão física.
if err := db.Ping(); err != nil {
return err
}

Nesse caso, o banco não é salvo em um arquivo permanente. As informações ficam disponíveis enquanto a conexão SQLite correspondente permanecer aberta.


Esse recurso pode ser interessante em testes ou operações temporárias que não precisam manter informações depois que a aplicação termina.


Existe um cuidado específico ao trabalhar com :memory: e database/sql. Cada conexão física aberta com esse nome possui seu próprio banco em memória. Como o sql.DB gerencia um pool de conexões, diferentes conexões podem acabar acessando bancos diferentes.


Por isso, no exemplo, limitamos o pool a uma conexão. Também existem formas de compartilhar um banco em memória entre conexões, mas elas dependem de uma configuração específica do SQLite.



Conectando no banco​


Depois de entender como o arquivo SQLite é criado, precisamos entender como a aplicação estabelece a comunicação com ele.


No Go, essa comunicação acontece por meio do database/sql, que trabalha com o driver responsável pelo banco escolhido.


O processo básico envolve criar o gerenciador de conexões, verificar se o banco está acessível e manter esse gerenciador disponível enquanto a aplicação precisar executar operações SQL.

        Aplicação Go
|
| sql.Open()
v
sql.DB
|
| Gerencia as conexões
v
Driver SQLite
|
| Acessa o arquivo
v
dados.db

O sql.DB não representa uma conexão individual. Ele gerencia um conjunto de conexões que podem ser abertas e reaproveitadas durante a execução da aplicação.


Para uma aplicação simples com SQLite, podemos limitar o pool a uma conexão.

// Cria o gerenciador de conexões.
db, err := sql.Open("sqlite", "dados.db")
if err != nil {
return err
}

// Limita o pool a uma conexão aberta.
db.SetMaxOpenConns(1)

// Permite manter uma conexão ociosa para reaproveitamento.
db.SetMaxIdleConns(1)

// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return err
}

O método SetMaxOpenConns(1) limita a quantidade de conexões físicas abertas simultaneamente. Já SetMaxIdleConns(1) permite que uma conexão permaneça aberta mesmo quando não estiver executando uma operação.


Essa configuração não é obrigatória para todo projeto SQLite, mas simplifica o funcionamento em aplicações pequenas. Como o SQLite permite apenas uma operação de escrita por vez em cada arquivo, limitar as conexões também pode reduzir disputas entre operações de escrita.


Tratando erros de conexão​


Uma tentativa de conexão pode falhar por diferentes motivos, como um caminho inválido, falta de permissão para acessar o arquivo ou problemas na configuração do driver.


Por isso, precisamos verificar os erros retornados pelos métodos de acesso ao banco.

package main

import (
"database/sql"
"fmt"
"log"

_ "modernc.org/sqlite"
)

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Cria o gerenciador de conexões.
db, err := sql.Open("sqlite", "dados.db")
if err != nil {
return fmt.Errorf("erro ao abrir banco: %w", err)
}

defer db.Close()

// Verifica se a conexão pode ser estabelecida.
if err := db.Ping(); err != nil {
return fmt.Errorf("erro ao conectar no banco: %w", err)
}

fmt.Println("Conexão estabelecida!")

return nil
}

Nesse exemplo, se sql.Open() ou db.Ping() retornar um erro, a função executar() devolve esse erro para a função main().


O %w, usado em fmt.Errorf(), permite adicionar uma mensagem ao erro original sem perder suas informações. Isso facilita identificar em qual operação o problema aconteceu.


Também colocamos o log.Fatal() na função main(), depois da chamada de executar(), porque log.Fatal() encerra o processo imediatamente e não executa chamadas pendentes de defer.


Dessa forma, se ocorrer um erro depois da abertura do banco, a função executar() termina normalmente por meio de return, permitindo que db.Close() seja executado.


Uma conexão para várias operações​


Não precisamos abrir um novo sql.DB sempre que quisermos executar uma consulta.


O mais comum é criar o gerenciador quando a aplicação inicia e passar o *sql.DB para as funções que precisam acessar o banco.

// Recebe o gerenciador já existente.
func verificarBanco(db *sql.DB) error {
// Executa uma consulta simples.
return db.Ping()
}

O *sql.DB pode ser compartilhado entre diferentes funções e goroutines. O próprio pacote database/sql é responsável por gerenciar o acesso concorrente às conexões.


Quando a aplicação não precisar mais do banco, chamamos db.Close() para encerrar o gerenciador e liberar os recursos associados.



Criando tabelas​


Depois de criar o banco e estabelecer a conexão, precisamos definir como as informações serão armazenadas.


No SQLite, os dados relacionais normalmente são organizados em tabelas. Cada tabela possui colunas, que representam os campos de informação, e linhas, que representam os registros armazenados.


Para criar uma tabela, usamos o comando SQL CREATE TABLE.


Imagine que precisamos armazenar usuários, identificados por um ID, um nome e um e-mail.

-- Cria uma tabela chamada usuarios.
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);

O comando CREATE TABLE indica que queremos criar uma nova tabela. O nome usuarios identifica essa tabela dentro do banco.


Dentro dos parênteses, definimos as colunas e suas características.


A coluna id possui o tipo INTEGER, que representa números inteiros, e a restrição PRIMARY KEY, que define a chave primária da tabela.


As colunas nome e email possuem o tipo TEXT, responsável por armazenar informações textuais. A restrição NOT NULL indica que esses campos não podem receber o valor NULL.


Criando tabelas com Go​


Para executar o comando CREATE TABLE pelo Go, podemos usar o método Exec() do database/sql.


Esse método é usado para executar comandos SQL que não precisam retornar linhas de resultado, como criação de tabelas, inserções, atualizações e remoções.

// Define o comando SQL responsável por criar a tabela.
query := `
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);
`

// Executa o comando no banco.
_, err := db.Exec(query)
if err != nil {
return err
}

A variável query armazena o comando SQL que será enviado ao banco.


Usamos crases para criar uma string que pode ocupar várias linhas. Isso permite escrever comandos SQL maiores sem precisar concatenar várias strings.


O método db.Exec(query) envia o comando ao driver, que executa a operação no SQLite.


O primeiro valor retornado é um sql.Result, que fornece informações sobre a execução de determinados comandos. Como não precisamos desse resultado ao criar a tabela, usamos _ para ignorá-lo.


O segundo retorno é um error, que permite verificar se a operação foi executada corretamente.


Criando tabelas apenas quando não existem​


Se executarmos novamente o comando CREATE TABLE usuarios, o SQLite retornará um erro caso a tabela já exista.


Para evitar esse erro, podemos adicionar IF NOT EXISTS ao comando.

-- Cria a tabela apenas se ela ainda não existir.
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);

Com IF NOT EXISTS, o SQLite verifica se já existe uma tabela ou view com o nome informado. Se existir, o comando não cria outra tabela com o mesmo nome.


Essa estrutura é bastante comum durante a inicialização de aplicações pequenas que precisam garantir a existência das tabelas.


Entretanto, IF NOT EXISTS não verifica se a estrutura da tabela existente corresponde à estrutura informada no comando.


Imagine que a tabela usuarios foi criada inicialmente com apenas as colunas id e nome. Depois, adicionamos a coluna email ao comando CREATE TABLE IF NOT EXISTS.


Ao executar novamente a aplicação, a coluna email não será adicionada automaticamente, porque a tabela já existe e o SQLite simplesmente ignora sua criação.


Para modificar tabelas existentes, precisamos executar comandos de alteração de estrutura, como ALTER TABLE, ou trabalhar com migrations, que serão estudadas posteriormente.


Verificando se uma tabela existe​


O SQLite mantém informações sobre a estrutura do banco em uma tabela de sistema chamada sqlite_schema.


Podemos consultar essa estrutura para verificar se determinada tabela existe.

-- Consulta a estrutura do banco.
SELECT name
FROM sqlite_schema
WHERE type = 'table'
AND name = 'usuarios';

Nesse exemplo, estamos procurando um objeto do tipo table cujo nome seja usuarios.


Se a tabela existir, a consulta retornará seu nome. Caso contrário, não retornará nenhum registro.


Também podemos consultar as informações das colunas de uma tabela por meio de um comando PRAGMA.

-- Mostra informações sobre as colunas da tabela.
PRAGMA table_info(usuarios);

O PRAGMA é um mecanismo próprio do SQLite, usado para consultar ou modificar determinadas configurações e informações internas do banco.


Nesse caso, table_info retorna informações como o nome das colunas, os tipos declarados, os valores padrão e a participação na chave primária.



Definindo tipos de dados​


Quando criamos uma tabela, normalmente definimos um tipo de dado para cada coluna. Isso ajuda a representar quais informações esperamos armazenar em cada campo.


O SQLite possui uma característica diferente de bancos como PostgreSQL e MySQL. Ele trabalha com um sistema de tipos dinâmico, no qual o tipo de armazenamento está associado principalmente ao valor armazenado, e não apenas à coluna que recebe esse valor.


Isso significa que, em tabelas comuns, declarar uma coluna como INTEGER não impede necessariamente que ela receba um texto que não possa ser convertido para número.


Para entender esse comportamento, precisamos conhecer as cinco classes de armazenamento do SQLite.


ClasseDescrição
NULLRepresenta a ausência de um valor.
INTEGERArmazena números inteiros.
REALArmazena números de ponto flutuante.
TEXTArmazena informações textuais.
BLOBArmazena dados binários sem conversão.

Essas classes representam como os valores são armazenados internamente. Na declaração de uma coluna, o SQLite também aceita nomes de tipos que determinam sua afinidade.


INTEGER​


O INTEGER é usado para representar números inteiros, ou seja, números que não possuem parte decimal.


Podemos usar esse tipo para armazenar identificadores, quantidades e outros valores inteiros.

CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
quantidade INTEGER NOT NULL
);

Nesse exemplo, a coluna quantidade foi definida como INTEGER, porque esperamos armazenar quantidades inteiras de produtos.


O SQLite armazena valores inteiros com diferentes tamanhos internos, de acordo com o valor informado, podendo chegar a inteiros com sinal de 64 bits.


No Go, esses valores podem ser representados por tipos inteiros compatíveis, como int64.


REAL​


O REAL é usado para armazenar números de ponto flutuante, que permitem representar valores com parte decimal.

CREATE TABLE medidas (
id INTEGER PRIMARY KEY,
altura REAL NOT NULL,
peso REAL NOT NULL
);

Nesse exemplo, as colunas altura e peso foram definidas como REAL, porque podem armazenar valores com casas decimais.


O SQLite representa valores REAL usando números de ponto flutuante de 64 bits, seguindo o formato IEEE 754.


Esse formato não consegue representar todos os valores decimais exatamente. Por isso, precisamos ter cuidado ao armazenar informações financeiras.


Para valores monetários que precisam de precisão exata, uma alternativa comum é armazenar o valor em centavos usando INTEGER.

CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Armazena o preço em centavos.
preco_centavos INTEGER NOT NULL
);

Por exemplo, um produto que custa R$ 19,90 pode ser armazenado com o valor inteiro 1990. Isso evita os problemas de precisão que podem surgir ao trabalhar com valores monetários usando ponto flutuante.


TEXT​


O TEXT é usado para armazenar informações textuais, como nomes, e-mails, descrições e endereços.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);

O SQLite aceita diferentes codificações de texto, como UTF-8 e UTF-16, de acordo com a configuração do banco.


Uma característica importante é que declarar uma coluna como VARCHAR(50) não impõe automaticamente um limite de 50 caracteres no SQLite.

CREATE TABLE categorias (
id INTEGER PRIMARY KEY,
nome VARCHAR(50)
);

Embora o SQLite aceite essa declaração, o número 50 não estabelece um limite de tamanho para os valores armazenados.


Se precisarmos restringir a quantidade de caracteres, podemos adicionar uma condição CHECK.

CREATE TABLE categorias (
id INTEGER PRIMARY KEY,

-- Permite nomes com até 50 caracteres.
nome TEXT NOT NULL CHECK (length(nome) <= 50)
);

O CHECK é uma restrição que verifica uma condição durante operações de inserção ou atualização. Nesse exemplo, a condição impede que valores com mais de 50 caracteres sejam armazenados na coluna nome.


BLOB​


O BLOB é usado para armazenar dados binários, como bytes de arquivos, imagens e outros conteúdos que não precisam ser interpretados como texto.

CREATE TABLE arquivos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Armazena o conteúdo binário do arquivo.
conteudo BLOB NOT NULL
);

No Go, podemos representar dados binários por meio de um slice de bytes, usando o tipo []byte.


Embora seja possível armazenar arquivos diretamente no SQLite, também podemos manter os arquivos em outro local e armazenar apenas seus caminhos ou identificadores no banco.


A escolha depende das necessidades da aplicação e de como esses arquivos serão acessados.


NULL​


O NULL representa a ausência de um valor.


Diferentemente de INTEGER, REAL, TEXT e BLOB, ele não costuma ser declarado como tipo de uma coluna. É uma classe de armazenamento que representa a ausência de informação.


Imagine uma tabela que armazena usuários e seus números de telefone.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
telefone TEXT
);

Nesse exemplo, a coluna telefone não possui a restrição NOT NULL. Isso significa que podemos cadastrar um usuário sem informar um telefone.


Quando não existe um valor informado nem um valor padrão definido, o SQLite armazena NULL nessa coluna.


É importante entender que NULL não representa o número 0 e também não representa uma string vazia "".


O valor NULL indica que não existe um valor armazenado naquele campo.


Booleanos no SQLite​


O SQLite não possui uma classe de armazenamento específica para valores booleanos.


Um booleano representa uma condição verdadeira ou falsa, que normalmente é expressa em Go pelo tipo bool.


No SQLite, esses valores são representados usando inteiros.

ValorSignificado
0Falso
1Verdadeiro

Podemos criar uma coluna para indicar se determinado usuário está ativo.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Representa falso ou verdadeiro.
ativo INTEGER NOT NULL DEFAULT 1
);

Nesse exemplo, o valor 1 representa um usuário ativo, enquanto 0 representa um usuário inativo.


O SQLite também reconhece as palavras TRUE e FALSE, que normalmente correspondem aos valores inteiros 1 e 0.


Entretanto, declarar uma coluna como INTEGER não impede, por si só, que outros números sejam armazenados. Se quisermos permitir apenas 0 e 1, podemos adicionar uma restrição.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Aceita apenas 0 ou 1.
ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1))
);

Agora, se tentarmos armazenar o valor 5 na coluna ativo, o SQLite rejeitará a operação por causa da condição definida no CHECK.


Datas no SQLite​


O SQLite também não possui uma classe de armazenamento exclusiva para datas e horários.


Essas informações podem ser representadas de diferentes maneiras, como texto, números inteiros ou números reais.


Uma forma comum é armazenar datas e horários como TEXT.

CREATE TABLE eventos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Armazena a data e o horário como texto.
criado_em TEXT NOT NULL
);

Nesse caso, podemos armazenar um valor como:

2026-10-12 14:30:00

Esse formato representa uma data e um horário. O SQLite possui funções próprias para trabalhar com datas, permitindo consultar, comparar e converter valores armazenados em formatos reconhecidos.


Também podemos armazenar horários como números inteiros que representam o tempo Unix, ou seja, a quantidade de segundos decorridos desde 1º de janeiro de 1970, em UTC.


A escolha do formato precisa ser consistente dentro da aplicação. Para os próximos exemplos, vamos trabalhar com datas armazenadas como TEXT.


Afinidade de tipos​


Como o SQLite trabalha com tipos dinâmicos, precisamos entender o conceito de afinidade de tipos (type affinity).


A afinidade representa o tipo de armazenamento que o SQLite prefere para os valores de uma coluna. Ela não funciona como uma restrição rígida nas tabelas comuns.


O SQLite possui cinco afinidades.

AfinidadeComportamento
INTEGERPrefere representar valores como números inteiros.
REALPrefere representar valores como números de ponto flutuante.
TEXTPrefere representar valores como texto.
NUMERICTenta representar valores numéricos como INTEGER ou REAL.
BLOBNão força a conversão para um tipo específico.

Por exemplo, podemos criar uma coluna declarada como INTEGER e tentar inserir um texto.

CREATE TABLE exemplos (
valor INTEGER
);

-- Insere um texto que não representa um número.
INSERT INTO exemplos (valor)
VALUES ('abc');

Em uma tabela SQLite comum, essa operação pode ser aceita, mesmo que a coluna tenha sido declarada como INTEGER.


Isso acontece porque o SQLite tenta aplicar a afinidade da coluna, mas, quando não consegue converter o valor sem perder informações, pode manter o tipo original.


Para verificar a classe de armazenamento de um valor, podemos usar a função typeof().

-- Verifica como o valor foi armazenado.
SELECT valor, typeof(valor)
FROM exemplos;

Nesse caso, a função retornaria text para o valor 'abc'.


Esse comportamento é diferente do que normalmente encontramos em bancos com tipagem mais rígida.


Tabelas STRICT​


A partir da versão 3.37.0, o SQLite passou a oferecer tabelas STRICT, que permitem aplicar regras mais rígidas aos tipos de dados.


Para criar uma tabela desse tipo, adicionamos a palavra STRICT ao final do comando CREATE TABLE.

CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
quantidade INTEGER NOT NULL,
preco REAL NOT NULL
) STRICT;

Nesse caso, o SQLite exige que os valores sejam compatíveis com os tipos declarados. Conversões que preservam o valor ainda podem acontecer, mas valores incompatíveis são rejeitados.


Por exemplo, inserir o texto 'abc' na coluna quantidade provocaria um erro, porque esse valor não pode ser convertido para um inteiro sem perder informações.


As tabelas STRICT aceitam os tipos INT, INTEGER, REAL, TEXT, BLOB e ANY. Por isso, nomes como BOOLEAN, DATE e VARCHAR(50) não são aceitos diretamente nesse modo.


O tipo ANY permite armazenar valores de diferentes classes sem forçar uma conversão.


O modo STRICT não é obrigatório, mas pode ser interessante em aplicações que precisam de regras mais rígidas para os tipos armazenados.



Chaves primárias​


Uma chave primária, representada por PRIMARY KEY, é uma restrição que permite identificar registros de uma tabela.


Imagine uma tabela que armazena usuários. Podemos ter dois usuários com o mesmo nome, mas precisamos de uma maneira de identificar cada registro individualmente.


Para isso, podemos criar uma coluna id e defini-la como chave primária.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL
);

Nesse exemplo, a coluna id identifica cada usuário armazenado.


A chave primária também estabelece uma regra de unicidade, impedindo que dois registros possuam o mesmo valor de chave. Isso permite consultar, atualizar ou remover um registro específico sem depender de informações como o nome do usuário.


Uma tabela pode possuir apenas uma restrição PRIMARY KEY, mas essa restrição pode envolver uma ou várias colunas.


Como o SQLite gera os identificadores?​


O SQLite possui um mecanismo interno chamado rowid, que representa um identificador inteiro associado aos registros das tabelas comuns que trabalham com esse recurso.


Quando definimos uma coluna exatamente como INTEGER PRIMARY KEY, ela normalmente se torna um nome alternativo para o rowid.


Isso permite que o SQLite gere automaticamente um identificador quando não informamos um valor para essa coluna durante a inserção.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL
);

-- Insere um usuário sem informar o ID.
INSERT INTO usuarios (nome)
VALUES ('João');

-- Insere outro usuário.
INSERT INTO usuarios (nome)
VALUES ('Maria');

Nesse exemplo, não informamos valores para a coluna id. O SQLite escolhe automaticamente identificadores inteiros que ainda não estão em uso.


Em uma tabela recém-criada, sem identificadores informados manualmente, os primeiros registros normalmente receberiam os valores 1 e 2.


O comportamento não garante que os números sempre serão sequenciais ou que nunca serão reaproveitados depois da remoção de registros.


Diferença entre INTEGER PRIMARY KEY e AUTOINCREMENT​


Uma dúvida comum é se precisamos adicionar AUTOINCREMENT para que o SQLite gere IDs automaticamente.


Na maioria dos casos, não precisamos.


A declaração abaixo já permite que o SQLite escolha automaticamente o identificador.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL
);

Também podemos declarar a coluna usando AUTOINCREMENT.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL
);

A principal diferença está na maneira como o SQLite escolhe novos identificadores.


Com apenas INTEGER PRIMARY KEY, o SQLite normalmente escolhe um valor maior que o maior identificador existente. Se determinados registros forem removidos, alguns identificadores anteriores poderão ser reaproveitados.


Com AUTOINCREMENT, o SQLite altera esse comportamento para impedir que identificadores gerados automaticamente reaproveitem valores que já foram usados em registros confirmados da mesma tabela.


Para controlar essa sequência, o SQLite mantém informações em uma tabela interna chamada sqlite_sequence.


Esse mecanismo exige trabalho adicional de processamento e armazenamento. Por isso, a própria documentação oficial do SQLite recomenda evitar AUTOINCREMENT quando esse comportamento específico não for necessário.


Para a maioria dos cadastros simples, INTEGER PRIMARY KEY já é suficiente.


Também precisamos observar que INTEGER PRIMARY KEY possui um comportamento especial. Declarar INT PRIMARY KEY, por exemplo, não produz exatamente o mesmo resultado, porque a associação automática com o rowid depende do nome de tipo INTEGER.


Chaves primárias compostas​


Uma chave primária também pode ser formada por mais de uma coluna.


Esse tipo de chave é chamado de chave primária composta.


Imagine uma tabela que relaciona pedidos e produtos. Um pedido pode possuir vários produtos, mas queremos impedir que o mesmo produto seja repetido em duas linhas desse pedido.

CREATE TABLE itens_pedido (
pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL,
quantidade INTEGER NOT NULL,

-- A combinação das duas colunas identifica cada registro.
PRIMARY KEY (pedido_id, produto_id)
);

Nesse exemplo, a chave primária não depende apenas de pedido_id ou de produto_id. Ela depende da combinação dos dois valores.


Isso significa que um pedido pode possuir vários produtos e que um produto pode aparecer em pedidos diferentes, mas não podemos repetir a mesma combinação de pedido e produto.


Existe uma particularidade no SQLite que precisa ser considerada. Em tabelas comuns, algumas chaves primárias que não são INTEGER PRIMARY KEY podem aceitar valores NULL, devido a um comportamento histórico do banco.


Por isso, quando trabalhamos com chaves primárias compostas, é recomendado declarar explicitamente as colunas como NOT NULL, como fizemos no exemplo. Em tabelas STRICT ou WITHOUT ROWID, o SQLite também impõe regras mais rígidas para essas chaves.


PRIMARY KEY e UNIQUE​


O SQLite também oferece uma restrição chamada UNIQUE, que impede a repetição de determinados valores.


Podemos usar UNIQUE em uma coluna que precisa armazenar valores diferentes, mas que não representa a chave primária da tabela.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Impede o cadastro de e-mails repetidos.
email TEXT NOT NULL UNIQUE
);

Nesse exemplo, id representa a chave primária, enquanto email possui uma restrição de unicidade.


Isso permite que cada usuário tenha seu próprio identificador e impede que dois registros recebam o mesmo e-mail.


A diferença é que uma tabela possui apenas uma chave primária, enquanto pode possuir várias restrições UNIQUE.


Outra particularidade é que o SQLite permite vários valores NULL em colunas com UNIQUE, porque considera esses valores distintos para a verificação de unicidade. Se o campo também precisa ser obrigatório, devemos combinar UNIQUE com NOT NULL.



Campos obrigatórios e opcionais​


Nem todas as informações de uma tabela precisam ser obrigatórias.


Imagine um cadastro de usuários que armazena nome, e-mail, idade e telefone. Podemos exigir nome e e-mail, mas permitir que idade e telefone sejam informados posteriormente.


Para representar essa diferença, usamos principalmente a restrição NOT NULL.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,

-- Campos obrigatórios.
nome TEXT NOT NULL,
email TEXT NOT NULL,

-- Campos opcionais.
idade INTEGER,
telefone TEXT
);

Nesse exemplo, as colunas nome e email não podem armazenar NULL. Já idade e telefone podem receber esse valor.


Se tentarmos inserir um usuário sem informar o nome, o SQLite rejeitará a operação, porque a coluna possui a restrição NOT NULL e não tem um valor padrão definido.


O que acontece quando um campo não é informado?​


Imagine que precisamos cadastrar um usuário, mas ainda não temos seu telefone.

-- Insere apenas os campos informados.
INSERT INTO usuarios (nome, email)
VALUES ('João', 'joao@email.com');

Como as colunas idade e telefone não foram informadas e não possuem valores padrão definidos, o SQLite armazena NULL nesses campos.


O registro poderia ser representado assim.

idnomeemailidadetelefone
1Joãojoao@email.comNULLNULL

Isso permite criar registros mesmo quando determinadas informações ainda não estão disponíveis.


Diferença entre NULL e uma string vazia​


Existe uma diferença entre armazenar NULL e armazenar uma string vazia.

-- Armazena NULL no telefone.
INSERT INTO usuarios (nome, email, telefone)
VALUES ('Maria', 'maria@email.com', NULL);

-- Armazena uma string vazia no telefone.
INSERT INTO usuarios (nome, email, telefone)
VALUES ('Pedro', 'pedro@email.com', '');

No primeiro caso, a coluna telefone recebe NULL, que representa a ausência de um valor.


No segundo caso, a coluna recebe uma string vazia, que continua sendo um valor de texto, embora não contenha caracteres.


Essa diferença também aparece quando trabalhamos com NOT NULL. A restrição impede valores NULL, mas não impede automaticamente strings vazias.


Por exemplo, a declaração abaixo permite uma string vazia.

nome TEXT NOT NULL

Se também precisarmos impedir nomes vazios, podemos adicionar uma condição.

-- Exige um texto com pelo menos um caractere não vazio.
nome TEXT NOT NULL CHECK (length(trim(nome)) > 0)

A função trim() remove espaços comuns das extremidades do texto, enquanto length() verifica a quantidade de caracteres restantes.


Dessa forma, a condição rejeita textos vazios ou formados apenas por esses espaços.


Trabalhando com campos opcionais no Go​


Quando buscamos informações do banco, precisamos considerar que alguns campos podem conter NULL.


Imagine que executamos uma consulta para buscar o telefone de um usuário.

SELECT telefone
FROM usuarios
WHERE id = 1;

Se o telefone não tiver sido informado, o resultado será NULL.


No Go, não podemos simplesmente receber um NULL em uma variável string comum por meio de Scan(), porque esse tipo não representa a ausência de um valor.


Para isso, o pacote database/sql oferece tipos como sql.NullString e sql.NullInt64.

// Representa um campo de texto que pode conter NULL.
var telefone sql.NullString

// Busca o telefone de um usuário.
err := db.QueryRow(
"SELECT telefone FROM usuarios WHERE id = ?",
1,
).Scan(&telefone)

if err != nil {
return err
}

// Verifica se o campo possui um valor.
if telefone.Valid {
fmt.Println("Telefone:", telefone.String)
} else {
fmt.Println("Telefone não informado")
}

O tipo sql.NullString possui dois campos principais. String armazena o texto recebido e Valid indica se o valor retornado pelo banco não era NULL.


Quando Valid é igual a false, significa que o banco retornou NULL. Quando é igual a true, podemos acessar o conteúdo por meio de telefone.String.


O mesmo conceito pode ser aplicado a números usando tipos como sql.NullInt64.


Mais adiante, ao trabalhar com structs e consultas SQL, vamos explorar melhor como representar esses campos na aplicação.



Valores padrão​


Um valor padrão é uma informação que o banco pode preencher automaticamente quando uma coluna não é informada durante a inserção de um registro.


No SQLite, definimos valores padrão por meio da cláusula DEFAULT.


Imagine um sistema de tarefas em que cada tarefa possui um título e um estado indicando se foi concluída.


Podemos definir que toda tarefa começa como não concluída.

CREATE TABLE tarefas (
id INTEGER PRIMARY KEY,
titulo TEXT NOT NULL,

-- Toda nova tarefa começa como não concluída.
concluida INTEGER NOT NULL DEFAULT 0
);

Nesse exemplo, a coluna concluida possui o valor padrão 0.


Isso significa que podemos cadastrar uma tarefa sem informar explicitamente seu estado.

-- Insere uma tarefa sem informar concluida.
INSERT INTO tarefas (titulo)
VALUES ('Estudar Go');

Como a coluna concluida não foi informada, o SQLite armazena o valor padrão definido na estrutura da tabela.


O resultado seria semelhante a este.

idtituloconcluida
1Estudar Go0

Também podemos informar um valor diferente durante a inserção.

-- Insere uma tarefa já concluída.
INSERT INTO tarefas (titulo, concluida)
VALUES ('Instalar SQLite', 1);

Nesse caso, o SQLite armazena o valor 1, porque ele foi informado explicitamente.


O DEFAULT não impede que outros valores sejam informados. Ele apenas fornece um valor quando a coluna é omitida.


Valores padrão em diferentes tipos​


Podemos definir valores padrão para diferentes tipos de dados.

CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Quantidade inicial.
quantidade INTEGER NOT NULL DEFAULT 0,

-- Estado inicial do produto.
ativo INTEGER NOT NULL DEFAULT 1,

-- Categoria inicial.
categoria TEXT NOT NULL DEFAULT 'geral'
);

Nesse exemplo, usamos valores padrão numéricos e textuais.


Os números são informados diretamente, enquanto textos são colocados entre aspas simples.


Quando cadastramos um produto informando apenas seu nome, o SQLite preenche as outras colunas com os valores definidos em DEFAULT.

-- Cadastra um produto informando apenas seu nome.
INSERT INTO produtos (nome)
VALUES ('Teclado');

O registro seria semelhante a este.

idnomequantidadeativocategoria
1Teclado01geral

Diferença entre DEFAULT e NULL​


Uma característica importante é que o valor padrão normalmente é aplicado quando a coluna não aparece na inserção.


Se informarmos NULL explicitamente, o SQLite não substitui automaticamente esse valor pelo DEFAULT.


Considere a seguinte tabela.

CREATE TABLE configuracoes (
id INTEGER PRIMARY KEY,
tema TEXT NOT NULL DEFAULT 'claro'
);

Se inserirmos um registro sem informar a coluna tema, o valor padrão será aplicado.

-- O SQLite preenche tema com 'claro'.
INSERT INTO configuracoes DEFAULT VALUES;

O comando DEFAULT VALUES cria um registro usando os valores padrão das colunas. Ele funciona nesse exemplo porque não existe nenhuma coluna obrigatória sem valor padrão.


Agora, considere uma inserção em que informamos NULL explicitamente.

-- Tenta armazenar NULL na coluna tema.
INSERT INTO configuracoes (tema)
VALUES (NULL);

Essa operação será rejeitada, porque tema possui a restrição NOT NULL.


O SQLite não substitui o NULL informado pelo valor 'claro'. A restrição NOT NULL é verificada e impede a inserção.


Se a coluna aceitasse NULL, esse valor poderia ser armazenado mesmo com um DEFAULT definido.


Essa diferença é importante porque DEFAULT e NOT NULL possuem responsabilidades diferentes. O primeiro fornece um valor quando necessário, enquanto o segundo impede que a coluna armazene NULL.


Valores padrão com data e horário​


O SQLite também permite definir valores padrão relacionados à data e ao horário atual.


Um exemplo comum é registrar automaticamente quando determinado registro foi criado.

CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,

-- Registra automaticamente o momento da criação.
criado_em TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

A expressão CURRENT_TIMESTAMP retorna a data e o horário atuais em UTC, usando o formato YYYY-MM-DD HH:MM:SS.


Quando inserimos um usuário sem informar a coluna criado_em, o SQLite preenche essa informação automaticamente.

-- Insere um usuário.
INSERT INTO usuarios (nome)
VALUES ('João');

O registro receberá a data e o horário da inserção. Por exemplo, se a operação acontecer em 12 de outubro de 2026, às 15h30 em UTC, o valor será armazenado como:

2026-10-12 15:30:00

O SQLite também oferece CURRENT_DATE, que retorna a data atual, e CURRENT_TIME, que retorna o horário atual, ambos em UTC.


ExpressãoFormato
CURRENT_DATEYYYY-MM-DD
CURRENT_TIMEHH:MM:SS
CURRENT_TIMESTAMPYYYY-MM-DD HH:MM:SS

Essas expressões podem ser usadas diretamente na definição de valores padrão.


Também precisamos considerar que DEFAULT CURRENT_TIMESTAMP registra o horário quando o registro é criado. Ele não atualiza automaticamente esse campo sempre que o registro é modificado.


Se precisarmos controlar a data de atualização, teremos que implementar esse comportamento por meio da aplicação ou de mecanismos do próprio banco, como triggers.



Exemplo completo​


Agora podemos reunir os conceitos anteriores em uma aplicação Go responsável por criar um banco SQLite e definir uma tabela de usuários.


A tabela terá um identificador gerado automaticamente, campos obrigatórios e opcionais, restrições de validação e valores padrão.


Vamos começar definindo sua estrutura SQL.

CREATE TABLE IF NOT EXISTS usuarios (
-- Identificador do usuário.
id INTEGER PRIMARY KEY,

-- Informações obrigatórias.
nome TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,

-- Informações opcionais.
idade INTEGER CHECK (idade >= 0),
telefone TEXT,

-- Estado inicial do usuário.
ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1)),

-- Data e horário de criação.
criado_em TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

A coluna id identifica cada usuário e permite que o SQLite gere automaticamente seu valor.


As colunas nome e email são obrigatórias. Além disso, email possui a restrição UNIQUE, impedindo que dois registros armazenem o mesmo endereço de e-mail.


As colunas idade e telefone são opcionais. A coluna idade também possui uma condição que impede números negativos, mas continua permitindo NULL, porque uma condição CHECK cujo resultado é NULL não representa uma violação no SQLite.


A coluna ativo indica o estado do usuário, começando com o valor 1. A condição CHECK (ativo IN (0, 1)) impede que valores diferentes de 0 e 1 sejam armazenados.


Por fim, criado_em armazena automaticamente a data e o horário em que o usuário foi cadastrado.


Implementando no Go​


Podemos executar esse comando durante a inicialização da aplicação.

package main

import (
"database/sql"
"fmt"
"log"

_ "modernc.org/sqlite"
)

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Cria o gerenciador de conexões com SQLite.
db, err := sql.Open("sqlite", "dados.db")
if err != nil {
return fmt.Errorf("erro ao abrir banco: %w", err)
}

// Libera os recursos ao terminar a função.
defer db.Close()

// Configura o pool para manter uma conexão aberta.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)

// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return fmt.Errorf("erro ao conectar: %w", err)
}

// Define a estrutura da tabela.
query := `
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
idade INTEGER CHECK (idade >= 0),
telefone TEXT,
ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1)),
criado_em TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`

// Executa o comando responsável por criar a tabela.
if _, err := db.Exec(query); err != nil {
return fmt.Errorf("erro ao criar tabela: %w", err)
}

fmt.Println("Banco SQLite configurado com sucesso!")

return nil
}

Quando executamos essa aplicação, o Go cria o gerenciador de conexões e verifica se o SQLite está acessível.


Em seguida, o método Exec() executa o comando responsável por criar a tabela usuarios.


Como usamos CREATE TABLE IF NOT EXISTS, podemos executar o programa novamente sem receber um erro apenas porque a tabela já existe. Entretanto, essa instrução não modifica uma tabela que tenha sido criada anteriormente com outra estrutura.


Depois da execução, o projeto deverá possuir uma estrutura semelhante a esta.

meu-projeto/
main.go
go.mod
go.sum
dados.db

O arquivo dados.db contém o banco SQLite, incluindo a estrutura da tabela criada.


Inserindo um registro para verificar a estrutura​


Com a tabela criada, podemos executar uma inserção simples para observar como os campos obrigatórios, opcionais e os valores padrão trabalham juntos.

// Insere um usuário informando apenas nome e email.
resultado, err := db.Exec(
"INSERT INTO usuarios (nome, email) VALUES (?, ?)",
"João",
"joao@email.com",
)

if err != nil {
return err
}

// Recupera o identificador do registro inserido.
id, err := resultado.LastInsertId()
if err != nil {
return err
}

fmt.Println("Usuário cadastrado com ID:", id)

Esse trecho deve ser executado dentro de uma função que tenha acesso ao db e retorne um error, como a função executar() do exemplo anterior.


O comando INSERT INTO cadastra um novo registro na tabela usuarios.


Os símbolos ? representam parâmetros que recebem os valores informados separadamente. Isso permite passar os dados ao driver sem precisar concatená-los diretamente no comando SQL.


Nesse exemplo, informamos apenas nome e e-mail. O SQLite será responsável por gerar o identificador e preencher as colunas restantes de acordo com suas definições.


Como idade e telefone são opcionais e não possuem valores padrão, receberão NULL. A coluna ativo receberá 1 e criado_em receberá a data e o horário da inserção.


O método LastInsertId() permite recuperar o identificador gerado pela inserção, que corresponde ao rowid do registro nesse modelo de tabela.


Se executarmos a mesma inserção novamente com o mesmo e-mail, o SQLite retornará um erro, porque a coluna possui a restrição UNIQUE.


Consultando o registro​


Também podemos buscar o registro cadastrado para verificar os valores armazenados.

// Variáveis que receberão os dados.
var nome string
var email string
var idade sql.NullInt64
var telefone sql.NullString
var ativo int64
var criadoEm string

// Busca o usuário pelo identificador.
err = db.QueryRow(`
SELECT nome, email, idade, telefone, ativo, criado_em
FROM usuarios
WHERE id = ?
`, id).Scan(
&nome,
&email,
&idade,
&telefone,
&ativo,
&criadoEm,
)

if err != nil {
return err
}

// Exibe os campos obrigatórios.
fmt.Println("Nome:", nome)
fmt.Println("Email:", email)

// Verifica os campos opcionais.
if idade.Valid {
fmt.Println("Idade:", idade.Int64)
} else {
fmt.Println("Idade não informada")
}

if telefone.Valid {
fmt.Println("Telefone:", telefone.String)
} else {
fmt.Println("Telefone não informado")
}

// Exibe os valores preenchidos pelo banco.
fmt.Println("Ativo:", ativo)
fmt.Println("Criado em:", criadoEm)

O método QueryRow() executa uma consulta em que esperamos receber um único registro. Nesse exemplo, usamos WHERE id = ? para buscar o usuário pelo identificador retornado na inserção.


O método Scan() copia os valores retornados pelo banco para as variáveis correspondentes.


Como idade e telefone podem receber NULL, usamos sql.NullInt64 e sql.NullString. Esses tipos permitem distinguir um campo que não possui valor de um campo que possui um número ou texto armazenado.


Já ativo foi declarado como INTEGER NOT NULL, então podemos recebê-lo em uma variável inteira. A coluna criado_em foi declarada como TEXT NOT NULL, permitindo que seu conteúdo seja recebido diretamente em uma string.


Essa consulta permite verificar como o SQLite aplicou as definições da tabela durante a inserção.