Query, Scan e o mapeamento manual
TL;DR
QueryContextdevolve um*sql.Rows— um cursor aberto sobre a rede, não uma lista pronta. O padrão é sempre o mesmo:defer rows.Close(), depoisfor rows.Next() { rows.Scan(&campo1, &campo2, ...) }, e por fim checarrows.Err(). Cada chamada aScancopia os valores da linha atual para os endereços passados — na ordem exata das colunas doSELECT, sem nome, sem reflection automática. Coluna que pode serNULLno banco quebra umScan(&string)em runtime; o antídoto são os tipossql.NullString,sql.NullInt64e primos, ou os tipos genéricossql.Null[T]desde Go 1.22. Não existe ORM por baixo do capô dodatabase/sql— o mapeamento struct↔linha é você quem escreve, campo a campo, e é exatamente esse trabalho manual que as notas seguintes (sqlc, GORM) tentam automatizar de formas diferentes.
O cursor que ninguém pediu para fechar
A nota anterior deixou o pool configurado — MaxOpenConns, MaxIdleConns, tudo ajustado. Agora vem a pergunta óbvia: como eu efetivamente leio dados?
A tentação de quem vem de um ORM é esperar algo como db.Find(&users) — chama um método, recebe uma slice pronta, pronto. O database/sql não trabalha assim. Ele devolve um cursor:
rows, err := db.QueryContext(ctx, "SELECT id, name, email FROM users WHERE active = $1", true)
if err != nil {
return nil, err
}
defer rows.Close()rows não contém as linhas. Contém uma conexão de rede ainda aberta com o Postgres, no meio de uma transferência de dados que ainda não terminou. Pense numa chamada de vídeo em andamento, não num arquivo já baixado: enquanto rows estiver “vivo”, a conexão do pool que ele usa está emprestada — presa, indisponível para qualquer outra query do seu programa, mesmo que você já tenha lido tudo que precisava.
Isso muda a pergunta “por que preciso fechar rows?” de detalhe de limpeza para requisito de correção: enquanto você não fecha (ou drena até o fim, o que fecha implicitamente), aquela conexão não volta para o pool. Num servidor com MaxOpenConns: 25 e tráfego concorrente, esquecer rows.Close() em um caminho de código é a receita clássica para o pool esgotar sob carga — sintoma que só aparece em produção, sob concorrência real, nunca no teste local com uma requisição por vez.
defer rows.Close()é seguro mesmo depois de drenar tudoChamar
Close()numa*sql.Rowsjá totalmente consumida (ou já fechada por erro) não tem efeito nocivo — o método é idempotente. Não existe motivo para não colocar odeferlogo depois doerr == nilchecado. A única forma de vazar conexão de verdade é não chamarClose()em nenhum branch — inclusive nos de erro no meio do loop.
O ciclo Next / Scan / Err
flowchart TD A["QueryContext"] --> B{"err != nil?"} B -- sim --> Z1["return err"] B -- não --> C["defer rows.Close()"] C --> D{"rows.Next()"} D -- "true: há próxima linha" --> E["rows.Scan(&campos...)"] E --> F{"err de Scan?"} F -- sim --> Z2["return err"] F -- não --> G["usa os valores escaneados"] G --> D D -- "false: acabou OU erro" --> H["rows.Err()"] H --> I{"err != nil?"} I -- sim --> Z3["erro veio da rede/driver\nno meio da iteração"] I -- não --> J["fim normal — todas\nas linhas foram lidas"] style D fill:#4A90D9,color:#fff style E fill:#F5A623,color:#000 style H fill:#D0021B,color:#fff
Três chamadas, três papéis distintos, que costumam ser confundidos por quem vem de APIs que devolvem uma lista pronta (ResultSet do JDBC tem um formato parecido, mas Hibernate/JPA escondem esse laço; em Python, cursor.fetchall() do psycopg2 já entrega tudo materializado):
rows.Next() bool— avança o cursor para a próxima linha e devolvetruese existe uma. Quando devolvefalse, pode significar duas coisas diferentes: acabaram as linhas (caso normal) ou um erro de rede/driver interrompeu a leitura no meio.Next()nunca conta qual dos dois foi — é papel do passo seguinte.rows.Scan(dest ...any) error— copia os valores da linha atual para os ponteiros passados, na ordem das colunas doSELECT. Errar a ordem, o tipo, ou a contagem de argumentos produz erro em runtime, não em compile-time:Scanusareflectinternamente para descobrir o tipo de cadadeste fazer a conversão.rows.Err() error— depois que o loopfor rows.Next()termina, é obrigatório checarrows.Err(). Se ele não fornil, o loop não terminou porque as linhas acabaram — terminou porque a conexão caiu, o context expirou, ou o driver reportou algum problema no meio da iteração. Pular esse check é o jeito mais comum de “engolir” um erro real e devolver dados incompletos como se fossem completos.
type User struct {
ID int64
Name string
Email string
}
func listActiveUsers(ctx context.Context, db *sql.DB) ([]User, error) {
rows, err := db.QueryContext(ctx, "SELECT id, name, email FROM users WHERE active = $1", true)
if err != nil {
return nil, fmt.Errorf("query users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}Repare no que não existe aqui: nenhuma tag de struct dizendo qual campo corresponde a qual coluna, nenhuma reflection que casa id com ID por nome. Scan(&u.ID, &u.Name, &u.Email) funciona porque a ordem dos ponteiros bate, campo a campo, com a ordem das colunas no SELECT id, name, email. Se alguém reordenar o SELECT para SELECT email, id, name sem atualizar o Scan, o código continua compilando — e passa a preencher ID com o e-mail e Name com o id, silenciosamente, até alguém notar que o app está gravando lixo.
Scannão valida nomes de coluna — só posição
database/sqlnão sabe que a segunda coluna doSELECTse chamaname. Ele só sabe que é a segunda, e que o segundo argumento deScané&u.Name. Mudar a ordem das colunas na query sem mudar a ordem doScanna mesma revisão é o bug mais comum e mais silencioso deste padrão — não gera erro de compilação nem de runtime, só dado errado.
QueryRowContext para uma linha só
Quando a query devolve no máximo uma linha — busca por chave primária, por exemplo — QueryRowContext evita o laço inteiro:
func getUserByID(ctx context.Context, db *sql.DB, id int64) (User, error) {
var u User
err := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return User{}, fmt.Errorf("user %d not found: %w", id, err)
}
if err != nil {
return User{}, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}QueryRowContext nunca devolve error diretamente — devolve um *sql.Row, e o erro só aparece quando você chama .Scan(...) nele. Isso é deliberado: encadear QueryRowContext(...).Scan(...) numa linha só é o padrão idiomático para consultas de uma linha, e o Close() da conexão subjacente já acontece dentro de Scan — não há rows.Close() para lembrar aqui.
O caso “nenhuma linha encontrada” não é um erro de conexão nem de sintaxe SQL — é sql.ErrNoRows, um valor sentinela exportado pelo pacote, comparável com errors.Is. Tratar “não encontrado” como caso de negócio normal (não como falha de infraestrutura) é o que separa um 404 limpo de um 500 genérico na camada acima.
NULL: o buraco que Scan não perdoa
SQL tem um terceiro estado que Go, por padrão, não tem: uma coluna NULL não é zero, não é string vazia — é ausência de valor. Um email VARCHAR NULL que não foi preenchido chega do banco como NULL, e tentar fazer Scan(&u.Email) direto em uma string comum produz erro em runtime:
var email string
err := rows.Scan(&id, &name, &email)
// err: sql: Scan error on column index 2, name "email":
// converting NULL to string is unsupportedO pacote database/sql resolve isso com uma família de tipos “nullable” — cada um empacota o valor primitivo junto com uma flag Valid bool:
type User struct {
ID int64
Name string
Email sql.NullString
}
func getUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
var u User
err := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return User{}, err
}
return u, nil
}
func printEmail(u User) {
if u.Email.Valid {
fmt.Println(u.Email.String)
} else {
fmt.Println("(sem e-mail cadastrado)")
}
}sql.NullString tem dois campos: String string e Valid bool. Scan preenche Valid: false (e String zerado) quando a coluna vem NULL, e Valid: true com o valor real caso contrário. O pacote inclui sql.NullInt64, sql.NullFloat64, sql.NullBool, sql.NullTime — um tipo por primitivo comum que pode ser nulo.
sql.Null[T]genérico — Go 1.22Desde a versão 1.22, o pacote ganhou
sql.Null[T any], um tipo genérico único que substitui a família inteira:sql.Null[string],sql.Null[int64],sql.Null[time.Time], atésql.Null[MeuTipoCustom]desde queMeuTipoCustomimplementeScanner/Valuer. Continua com os mesmos dois campos, agora genéricos:V TeValid bool. Bases de código mais novas tendem a preferirsql.Null[T]por não precisar de um tipoNullXdedicado para cada tipo custom — massql.NullStringe companhia continuam plenamente suportados e onipresentes em código legado.
// Equivalente com o tipo genérico (Go 1.22+):
type User struct {
ID int64
Name string
Email sql.Null[string]
}Ignorar essa realidade e escanear direto para string/int64/time.Time “porque a coluna nunca deveria ser NULL na teoria” é apostar que o schema nunca vai divergir do código — aposta que schemas de produção, com anos de migrations acumuladas, raramente pagam.
Valid: falsenão é o mesmo que erro
Scanbem-sucedido comValid: falsenão é uma falha — é o resultado correto e esperado quando a coluna éNULL. O erro só acontece quando você tenta escanear umNULLpara um tipo que não sabe representar ausência (string,int64crus). Confundir os dois leva a código que trata todoNULLdo banco como bug de dados, quando muitas vezes é o dado correto (endereço opcional vazio, telefone não informado).
Sem ORM, sem mágica — por design
Vale nomear o que fica implícito até aqui: database/sql não tenta mapear struct para tabela automaticamente. Não há tag db:"email" lida por reflection em tempo de execução como em bibliotecas de outras linguagens (JPA/Hibernate em Java, SQLAlchemy em Python, Sequelize/Prisma em Node). Cada Scan é uma lista explícita de ponteiros, escrita à mão, na ordem exata do SELECT.
Isso é uma escolha de design, não uma lacuna a ser preenchida cedo demais. A vantagem: zero mágica escondida — o que a query devolve é exatamente o que o Scan recebe, sem uma camada de reflection decidindo por baixo dos panos como converter cada coluna. O custo: repetição. Toda struct que representa uma linha de tabela ganha, cedo ou tarde, uma função scanUser(rows *sql.Rows) (User, error) ou parecida, reescrita a cada nova query com um SELECT ligeiramente diferente.
func scanUser(row interface{ Scan(...any) error }) (User, error) {
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
return u, err
}Por que Go não tem um
Scan(&u)que preenche a struct inteira sozinho, por reflection?Poderia ter — bibliotecas de terceiros como
sqlx(extensão popular sobredatabase/sql) oferecem exatamente isso, comdb.Get(&u, query, args...)lendo tagsdb:"..."via reflection. Odatabase/sqlda standard library, porém, segue a filosofia geral de Go de manter a API mínima e explícita, deixando esse tipo de conveniência para o ecossistema decidir se vale o custo de reflection e “magia” implícita. As próximas duas notas — sqlc (nota 05) e GORM (nota 06) — são exatamente as duas respostas mais populares do ecossistema para esse “custo do mapeamento manual”, cada uma com uma filosofia bem diferente: geração de código versus reflection em runtime.
Lente cross-stack
| Vindo de | Em Go, o equivalente é |
|---|---|
Java + JDBC cru (ResultSet) | Praticamente idêntico — rs.next()/rs.getString(i) vira rows.Next()/rows.Scan(&s), mesma filosofia de cursor manual |
| Java + Hibernate/JPA | Não existe por padrão — o “mapeamento automático por reflection + tags” fica para o GORM (nota 06), que é opcional e explícito |
Python + psycopg2 cru | cursor.fetchall() materializa tudo de uma vez; rows.Next()/Scan em Go é mais parecido com cursor.fetchone() em laço |
| Python + SQLAlchemy ORM | Sem equivalente na standard library — comparável ao GORM, não ao database/sql |
Node + pg (node-postgres) | result.rows já vem como array de objetos JS soltos; Go exige o Scan explícito por linha, sem essa conveniência dinâmica |
Como explicar em inglês
database/sqlreturns a live cursor fromQueryContext— a*sql.Rowsstill holding an open connection from the pool — not a materialized list. The idiom is alwaysdefer rows.Close(), thenfor rows.Next() { rows.Scan(&field1, &field2, ...) }, followed by a mandatoryrows.Err()check after the loop, sinceNext()returningfalsecan mean either “done” or “an error interrupted iteration.”Scanmaps values positionally, by column order in theSELECT— not by name — so reordering columns without updating theScancall compiles fine and silently corrupts data. NULL columns can’t be scanned into a barestringorint64; the fix issql.NullStringand its siblings, or the genericsql.Null[T]introduced in Go 1.22. There’s no reflection-based struct mapping here by design — everyScancall is explicit, which is exactly the manual cost that sqlc and GORM, covered next, each try to remove in very different ways.
| Termo PT | Termo EN |
|---|---|
| cursor / conjunto de resultados | rows / result set |
| escanear (uma linha) | scan (a row) |
| mapeamento manual | manual mapping |
| coluna anulável | nullable column |
| valor sentinela | sentinel value |
| esgotar o pool | exhaust the pool |
| mapeamento posicional | positional mapping |
O que vem a seguir
O Scan linha a linha resolve o essencial, mas expõe o teto do driver genérico database/sql: sem COPY, sem tipos nativos do Postgres como arrays e JSONB de primeira classe, sem pipelining. A nota 04 entra no pgx, o driver que a comunidade Go usa quando o alvo é especificamente Postgres e o database/sql genérico começa a faltar recurso.
Veja também
- sql — o contrato — a interface
*sql.DBe o modelo de driver que este capítulo pressupõe - 02 — Connection pool — por que
rows.Close()importa: a conexão emprestada porrowsvem exatamente desse pool - 04 — pgx — o driver Postgres avançado — próxima nota do galho
- 05 — sqlc — SQL type-safe por codegen — automatiza o
Scanmanual descrito aqui via geração de código - 06 — GORM — o ORM — automatiza o mesmo problema via reflection em runtime, filosofia oposta ao sqlc
- Trilha Go
Fontes
- The Go Authors. Package database/sql. pkg.go.dev. https://pkg.go.dev/database/sql (acessado em 2026-07-18)
- The Go Authors. database/sql tutorial — Retrieving result sets. go.dev/wiki. https://go.dev/wiki/SQLInterface (acessado em 2026-07-18)
- Go by Example. SQL Databases. gobyexample.com. https://gobyexample.com/sql-databases (acessado em 2026-07-18)
- The Go Blog. Go 1.22 Release Notes — database/sql. go.dev/doc. https://go.dev/doc/go1.22 (acessado em 2026-07-18)
- The Go Authors. Package database/sql — type Rows. pkg.go.dev. https://pkg.go.dev/database/sql#Rows (acessado em 2026-07-18)