Tags de struct em Go: reflexão, JSON e validação
Como o reflect lê as tags de struct em Go, como o encoding/json e os validadores as usam, e por que uma tag mal escrita passa sem nenhum erro.
Publicada em · 31 min de leitura
Imagine uma caixa de mudança com a etiqueta “cozinha, frágil” colada na lateral. O caminhão não lê etiqueta: carrega a caixa do mesmo jeito, diga ela o que disser. Quem lê a etiqueta é a pessoa que desembrulha a caixa no destino, e é ela que decide onde cada coisa vai. Uma em Go funciona com esse mesmo arranjo. É um texto colado ao lado de um campo, o compilador guarda esse texto sem entender o que ele diz, e alguma biblioteca o lê na hora de usar o dado. Se você colar a etiqueta torta, o caminhão não reclama. Você só descobre quando alguém procura a caixa de cozinha e ela não aparece em lugar nenhum.
Esta aula explica o que a linguagem faz com a tag (quase nada), como o pacote reflect lê o conteúdo dela, e como as bibliotecas mais usadas, encoding/json e validadores, transformam essa leitura em comportamento. Também mostra o que acontece quando a tag está mal escrita, porque nesse caso o Go não avisa nada, e quanto custa ler tags em tempo de execução.
Pré-requisito: Structs, porque a tag é sempre anexada a um campo de struct.
Mapa da aula
| Parte | Seções | O que você vai aprender |
|---|---|---|
| 1. Intuição e formato | 1 a 3 | O que é a tag, por que ela existe e quais bibliotecas a lêem |
| 2. Como a leitura funciona | 4 e 5 | O tipo StructTag, os métodos Get e Lookup e a regra do formato chave:"valor" |
| 3. Uso em JSON e validação | 6 e 7 | Renomear, omitir, omitempty, omitzero, ,string e validação por campo |
| 4. Quando o formato quebra | 8 e 9 | Tags malformadas que somem sem aviso, o que o go vet pega e a |
| 5. Custo e boas práticas | 10 e 11 | O que a leitura de tags custa, medido, e os erros que se repetem |
| 6. Prática e consolidação | 12 a 15 | Exercícios com gabarito, o que não foi confirmado, glossário e fontes |
1. O que é uma tag de struct
Tecnicamente, uma tag é um opcional escrito logo depois do tipo do campo:
type Usuario struct {
Nome string `json:"nome_completo" validate:"required"`
}
A especificação da linguagem descreve a tag em poucas frases. Ela pode vir após qualquer declaração de campo, é “visível por uma interface de ”, participa da identidade do tipo e, fora isso, é ignorada. O próprio texto da spec traz um exemplo em que um campo chamado _ recebe a tag "ceci n'est pas un champ de structure". Ou seja, o compilador aceita qualquer texto ali. Se o texto significa alguma coisa, quem decide é a biblioteca que vai ler.
As crases em volta da tag têm um motivo prático. Uma tag quase sempre contém aspas duplas, e um literal de string entre crases (chamado de ) deixa escrever " sem precisar de \". O preço é que a tag não pode conter crase. Uma tag vazia é equivalente a não ter tag, segundo a mesma spec.
Repare no que isso significa na prática: o compilador confere se a etiqueta está colada no campo, e não se o conteúdo faz sentido. Você pode escrever json:"nome com aspas faltando e o programa compila sem reclamar. A seção 8 mostra o que acontece depois disso.
2. Para que serve: informação colada ao campo
Pense num cadastro de usuário. A API envia o campo como nome_completo, a tabela do banco chama a coluna de full_name, e o formulário exige que o campo não venha vazio. Em Go, o campo se chama Nome, porque a linguagem exige letra maiúscula para exportar um campo. São quatro nomes para a mesma informação, e alguém precisa ligá-los.
Sem tags, a ligação ficaria numa tabela de mapeamento escrita à parte, uma para cada biblioteca. Essa tabela fica longe do campo, e aí mora o problema. Se alguém renomear Nome para NomeCompleto numa refatoração, a tabela continua apontando para o nome antigo. Nada quebra na compilação, e o erro aparece depois, como um campo que sumiu do JSON em produção.
A tag resolve isso colocando a informação na mesma linha do campo. Renomear o campo e ajustar a tag viram uma única edição, visível no diff do commit. O Go não traz uma lista de bibliotecas nem um sistema de anotações pronto. Ele oferece o espaço (o literal de string) e o pacote reflect para ler esse espaço em tempo de execução, e cada biblioteca decide o que ler ali.
3. Três contextos, a mesma ideia
Considere esta struct, que aparece em várias aulas da trilha:
type UsuarioDTO struct {
ID int64 `json:"id" db:"user_id"`
Nome string `json:"nome_completo" db:"full_name" validate:"required,min=3"`
SenhaHash string `json:"-" db:"password_hash"`
}
Cada campo carrega três chaves diferentes. A chave json é lida por encoding/json para montar e interpretar o JSON da API. A chave db é lida por bibliotecas de acesso a banco, como o SQLX, para ligar colunas aos campos. A chave validate é lida por validadores, que conferem regras como “obrigatório” ou “mínimo de três caracteres”.
A chave é um nome escolhido pela biblioteca, e o Go não sabe que json e db são coisas diferentes. Para o compilador, os três são só texto. Por isso uma mesma struct serve a três bibliotecas ao mesmo tempo, e cada uma lê apenas as chaves que conhece e ignora o resto. O pacote encoding/xml segue a mesma convenção com a chave xml. Essa parte eu não testei nesta aula, só a conheço pela convenção.
4. Como o reflect lê a tag
O pacote reflect da biblioteca padrão inspeciona tipos e valores em tempo de execução, mesmo quando o código que faz a inspeção não conhece o tipo na hora de ser escrito. json.Marshal precisa disso: ele recebe um any e precisa descobrir os campos da struct de dentro para fora.
Três tipos entram na conversa. reflect.Type descreve um tipo. reflect.StructField descreve um campo, com o nome, o tipo e a tag. E a tag aparece como reflect.StructTag, um tipo baseado em string que tem dois métodos de leitura: Get e Lookup.
O código abaixo lê as tags de uma struct e mostra a diferença entre os dois métodos:
package main
import (
"fmt"
"reflect"
)
type Usuario struct {
ID int64 `json:"id" db:"user_id"`
Nome string `json:"nome_completo" db:"full_name" validate:"required,min=3"`
SenhaHash string `json:"-" db:"password_hash"`
Apelido string `db:""`
Idade int
}
func main() {
t := reflect.TypeOf(Usuario{})
for i := 0; i < t.NumField(); i++ {
campo := t.Field(i)
fmt.Printf("campo %-10s tipo %-6s tag bruta: %s\n", campo.Name, campo.Type, campo.Tag)
fmt.Printf(" Get(\"json\") = %q\n", campo.Tag.Get("json"))
valor, ok := campo.Tag.Lookup("db")
fmt.Printf(" Lookup(\"db\") = %q, ok=%t\n", valor, ok)
}
fmt.Println()
campo, _ := t.FieldByName("Idade")
valor, ok := campo.Tag.Lookup("json")
fmt.Printf("Idade sem tag: Get=%q, Lookup=(%q, ok=%t)\n", campo.Tag.Get("json"), valor, ok)
campo, _ = t.FieldByName("Apelido")
valor, ok = campo.Tag.Lookup("db")
fmt.Printf("Apelido com db:\"\": Get=%q, Lookup=(%q, ok=%t)\n", campo.Tag.Get("db"), valor, ok)
}
Saída real:
campo ID tipo int64 tag bruta: json:"id" db:"user_id"
Get("json") = "id"
Lookup("db") = "user_id", ok=true
campo Nome tipo string tag bruta: json:"nome_completo" db:"full_name" validate:"required,min=3"
Get("json") = "nome_completo"
Lookup("db") = "full_name", ok=true
campo SenhaHash tipo string tag bruta: json:"-" db:"password_hash"
Get("json") = "-"
Lookup("db") = "password_hash", ok=true
campo Apelido tipo string tag bruta: db:""
Get("json") = ""
Lookup("db") = "", ok=true
campo Idade tipo int tag bruta:
Get("json") = ""
Lookup("db") = "", ok=false
Idade sem tag: Get="", Lookup=("", ok=false)
Apelido com db:"": Get="", Lookup=("", ok=true)
Get devolve a string associada à chave, ou vazio quando a chave não existe. O problema é que vazio também é o resultado de db:"", que é uma chave presente com valor vazio. Lookup resolve essa ambiguidade devolvendo um segundo valor booleano, ok, que diz se a chave estava escrita na tag. Use Get quando vazio e ausente tiverem o mesmo significado, e Lookup quando a diferença importar. A documentação do pacote diz exatamente isso: para saber se a tag foi definida como vazia, é preciso usar Lookup.
O formato chave:“valor”
A documentação de reflect.StructTag descreve a convenção do formato. Em resumo: a tag é uma concatenação de pares chave:"valor", separados por espaço opcional. Cada chave é um texto não vazio, sem caracteres de controle, sem espaço, sem aspas e sem dois-pontos. Cada valor fica entre aspas duplas e segue a sintaxe de string literal do Go.
A parte que mais surpreende é a exigência de uma sintaxe de string literal dentro da tag. Isso existe porque o valor é lido com strconv.Unquote, a mesma função que desfaz escapes de string em Go (confirmado no código-fonte de reflect/type.go, na função Lookup). Assim, o valor pode conter vírgulas, dois-pontos, espaços e até aspas escapadas, sem ambiguidade, porque a aspa de fechamento marca exatamente onde o valor termina. Já a chave não tem escape, por isso o formato proíbe espaços e dois-pontos nela.
Essa rigidez tem uma consequência concreta. A chave deve ficar colada à aspa de abertura, sem espaço entre os dois-pontos e as aspas. A seção 8 mostra o que acontece com json: "telefone", que tem um espaço a mais. O Get devolve vazio, e ninguém avisa.
5. Como a leitura funciona por dentro
A figura abaixo mostra o percurso que Lookup faz pela string da tag. Ele não usa expressões regulares nem uma tabela: é um laço que avança caractere por caractere, pula espaços, lê uma chave, confere a aspa e compara. A parte vermelha é o caso que importa para a seção 8.
O percurso começa na tag bruta, em azul, e avança por cinco passos. No passo 3, a leitura se divide. Quando a aspa aparece logo após os dois-pontos, o valor é lido e, se a chave bate, o método devolve o valor com ok = true (caminho verde). Quando a aspa não aparece, o laço não tem como continuar com segurança, e o resultado é o caminho vermelho: vazio, com ok = false, e nenhum erro. Na prática, o campo continua sendo processado, só que sem a informação que a tag deveria trazer.
O laço também explica por que a ordem das chaves não importa. Se a chave pedida não é a primeira, o método passa para o próximo par (caminho roxo, “não”) e volta ao passo 1. Por isso uma tag pode ter db antes de json, ou o contrário, e ambas são encontradas.
6. encoding/json: nome, omissão e opções
encoding/json é o uso mais comum de tags. Ao chamar json.Marshal, o pacote lê a chave json de cada campo exportado e decide o que escrever. Campos não exportados, que começam com letra minúscula, são ignorados sem aviso, porque o pacote não pode ler nem gravar o valor de um campo não exportado de outro pacote.
O exemplo abaixo mostra as principais opções numa só struct:
package main
import (
"encoding/json"
"fmt"
"time"
)
type Endereco struct {
Cidade string `json:"cidade,omitempty"`
}
type Usuario struct {
ID int64 `json:"id"`
Nome string `json:"nome_completo"`
Email string `json:"email,omitempty"`
Idade int `json:"idade,omitempty"`
Senha string `json:"-"`
Traco string `json:"-,"`
Ativo bool `json:"ativo,string"`
Criado time.Time `json:"criado,omitzero"`
Endereco `json:"endereco,omitempty"`
interna string
Sem string
}
func main() {
u := Usuario{
ID: 7,
Nome: "Ana",
Senha: "segredo",
Traco: "valor com hífen",
Ativo: true,
Endereco: Endereco{Cidade: "Recife"},
interna: "não sai",
Sem: "sem tag",
}
saida, err := json.Marshal(u)
if err != nil {
panic(err)
}
fmt.Println("Marshal:")
fmt.Println(string(saida))
vazio := Usuario{ID: 1}
saida, _ = json.Marshal(vazio)
fmt.Println("Marshal com campos zero:")
fmt.Println(string(saida))
entrada := `{"id": 9, "NOME_COMPLETO": "Bruno", "senha": "vem do JSON?", "-": "x", "ativo": "false", "idade": 30, "cidade": "Olinda"}`
var lido Usuario
if err := json.Unmarshal([]byte(entrada), &lido); err != nil {
panic(err)
}
fmt.Printf("Unmarshal: ID=%d Nome=%q Senha=%q Traco=%q Ativo=%t Idade=%d Cidade=%q\n",
lido.ID, lido.Nome, lido.Senha, lido.Traco, lido.Ativo, lido.Idade, lido.Cidade)
}
Saída real:
Marshal:
{"id":7,"nome_completo":"Ana","-":"valor com hífen","ativo":"true","endereco":{"cidade":"Recife"},"Sem":"sem tag"}
Marshal com campos zero:
{"id":1,"nome_completo":"","-":"","ativo":"false","endereco":{},"Sem":""}
Unmarshal: ID=9 Nome="Bruno" Senha="" Traco="x" Ativo=false Idade=30 Cidade=""
Cada linha da saída confirma uma regra. nome_completo aparece porque a tag renomeou o campo Nome. Senha some porque json:"-" manda ignorar o campo. A chave "-" aparece na saída porque json:"-," é a forma de dizer que o nome do campo é, de fato, um hífen. ativo sai entre aspas porque a opção ,string pede que o valor booleano seja escrito como texto. E o campo Sem, sem tag nenhuma, sai com o próprio nome Go.
As opções de omissão merecem atenção, porque são as mais usadas e as mais mal entendidas. A documentação de encoding/json diz que omitempty omite um campo quando o valor é “vazio”, definido como false, 0, ponteiro nulo, interface nula, e array, slice, map ou string vazios. Struct não está nessa lista, e por isso "endereco":{} aparece na segunda linha da saída mesmo com omitempty. Quando o campo Endereco for uma struct inteira zerada, o pacote a escreve como um objeto vazio. Se você quer omitir uma struct, a opção indicada é omitzero, que existe desde o Go 1.24 e omite o valor zero do tipo. Na linha de Criado, o time.Time zerado sumiu da saída por causa dela.
Na leitura, Unmarshal também usa as tags, e isso traz duas consequências que aparecem na última linha da saída. A primeira é que a correspondência de nomes ignora maiúsculas: NOME_COMPLETO foi para o campo Nome. A documentação diz que uma correspondência exata tem preferência sobre a que ignora caixa, mas aceita as duas. A segunda consequência é mais sutil: Endereco tem a tag json:"endereco,omitempty", então o JSON precisa trazer "endereco": {"cidade": ...}. O "cidade" solto no nível de cima não é mapeado, e o campo Cidade fica vazio. Dar tag a uma struct embutida muda o formato do JSON, e essa mudança vale nos dois sentidos.
A figura abaixo resume as decisões que o pacote toma para cada campo, na ordem em que as toma. Um campo só entra no JSON se passar por todas as portas.
A figura começa pelo campo da struct, em azul, e a primeira pergunta é se ele é exportado. Um campo minúsculo sai pelo caminho vermelho, e nenhum erro é reportado. A segunda pergunta trata o hífen: json:"-" omite o campo pelo caminho cinza, enquanto json:"-," cai no caminho verde e vira a chave "-". Os dois losangos roxos são as opções de omissão. Repare que a figura não tem um caminho para struct com omitempty, porque a regra não existe para struct. Quem atravessa todas as portas tem a chave escolhida, e o valor é escrito. Em verde, ele entra no JSON.
Vale lembrar que o encoding/json não relê as tags a cada chamada. O código-fonte do pacote guarda a lista de campos de cada tipo num cache (fieldCache, um sync.Map em encode.go), e a leitura acontece uma única vez por tipo. Esse detalhe volta na seção 10, quando medimos o que acontece com a leitura feita a cada chamada.
7. Validação: regras por campo
Validar uma entrada significa conferir regras que pertencem a cada campo: este é obrigatório, aquele tem de ter pelo menos três letras, aquele outro cai numa faixa de valores. Essas regras são exatamente o tipo de informação que a tag carrega bem, porque ficam na linha do campo e mudam junto com ele.
Antes de usar uma biblioteca, vale ver que a ideia cabe em poucas linhas. O validador abaixo lê a chave validate, separa as regras por vírgula, quebra cada uma em nome e parâmetro e aplica a regra ao valor do campo:
package main
import (
"fmt"
"reflect"
"strconv"
"strings"
)
type Cadastro struct {
Nome string `validate:"required,min=3"`
Idade int `validate:"min=18,max=120"`
Cidade string `validate:"required"`
Apelido string
}
func Validar(v any) []string {
var erros []string
valor := reflect.ValueOf(v)
tipo := valor.Type()
for i := 0; i < tipo.NumField(); i++ {
campo := tipo.Field(i)
regras := campo.Tag.Get("validate")
if regras == "" {
continue
}
for _, regra := range strings.Split(regras, ",") {
nome, param, _ := strings.Cut(regra, "=")
fv := valor.Field(i)
switch nome {
case "required":
if fv.IsZero() {
erros = append(erros, campo.Name+": required")
}
case "min":
n, _ := strconv.Atoi(param)
if (fv.Kind() == reflect.String && fv.Len() < n) || (fv.Kind() == reflect.Int && fv.Int() < int64(n)) {
erros = append(erros, fmt.Sprintf("%s: min=%d", campo.Name, n))
}
case "max":
n, _ := strconv.Atoi(param)
if fv.Kind() == reflect.Int && fv.Int() > int64(n) {
erros = append(erros, fmt.Sprintf("%s: max=%d", campo.Name, n))
}
}
}
}
return erros
}
func main() {
fmt.Println("Cadastro válido:", Validar(Cadastro{Nome: "Ana Lima", Idade: 30, Cidade: "Recife"}))
fmt.Println("Cadastro inválido:", Validar(Cadastro{Nome: "Al", Idade: 15}))
}
Saída real:
Cadastro válido: []
Cadastro inválido: [Nome: min=3 Idade: min=18 Cidade: required]
O campo Apelido não tem tag, então o validador o pula com o continue. O campo Nome falha em min=3 porque "Al" tem duas letras, mas passa em required. A ordem das regras dentro da tag importa para a leitura, e aqui o validador as aplica na ordem em que aparecem.
Uma biblioteca de validação faz a mesma leitura com muito mais cuidado. O projeto github.com/go-playground/validator/v10 é o validador mais usado no ecossistema Go. A versão mais recente que consegui confirmar no proxy de módulos é a v10.30.6, publicada em 29/10/2026. O código abaixo usa essa versão com o mesmo cadastro:
package main
import (
"fmt"
"github.com/go-playground/validator/v10"
)
type Cadastro struct {
Nome string `validate:"required,min=3"`
Idade int `validate:"min=18,max=120"`
Cidade string `validate:"required"`
Apelido string
}
func main() {
v := validator.New()
validos := Cadastro{Nome: "Ana Lima", Idade: 30, Cidade: "Recife"}
fmt.Println("Cadastro válido, erro =", v.Struct(validos))
invalido := Cadastro{Nome: "Al", Idade: 15}
err := v.Struct(invalido)
if err == nil {
fmt.Println("nenhum erro (inesperado)")
return
}
for _, e := range err.(validator.ValidationErrors) {
fmt.Printf("campo=%s regra=%s parametro=%q valor=%v\n", e.Field(), e.Tag(), e.Param(), e.Value())
}
}
Saída real:
Cadastro válido, erro = <nil>
campo=Nome regra=min parametro="3" valor=Al
campo=Idade regra=min parametro="18" valor=15
campo=Cidade regra=required parametro="" valor=
Os dois programas produzem os mesmos resultados para os mesmos dados. A diferença é que a biblioteca devolve um erro estruturado, que você consulta com Field, Tag, Param e Value, em vez de uma lista de textos. O que a biblioteca faz além disso, como regras próprias, tradução de mensagens e validação de campos aninhados, eu não testei nesta aula. A documentação do projeto descreve esses recursos, mas não os conferi aqui.
8. Quando a tag está errada e ninguém avisa
Aqui o Go fica calado, e é isso que torna o erro perigoso. A tag é só texto para o compilador, então uma tag mal escrita compila sem nenhum sinal. Quem paga a conta é o comportamento em tempo de execução, e às vezes ele paga com dados que não deveriam sair do sistema.
O programa abaixo mistura seis erros de formatação num mesmo struct. Cada campo tem um defeito diferente, e a saída mostra o que a biblioteca realmente enxerga:
package main
import (
"encoding/json"
"fmt"
"reflect"
)
type Conta struct {
Nome string `json:name`
Senha string `json:-`
Email string `json:"email,omitemtpy"`
Cpf string `josn:"cpf"`
Tel string `json: "telefone"`
token string `json:"token"`
}
func main() {
t := reflect.TypeOf(Conta{})
for i := 0; i < t.NumField(); i++ {
c := t.Field(i)
fmt.Printf("%-6s tag=%-22s Get(json)=%q\n", c.Name, string(c.Tag), c.Tag.Get("json"))
}
conta := Conta{Nome: "Ana", Senha: "s3cr3to", Cpf: "123", Tel: "81 99999-0000", token: "abc"}
saida, _ := json.Marshal(conta)
fmt.Println(string(saida))
}
Saída real:
Nome tag=json:name Get(json)=""
Senha tag=json:- Get(json)=""
Email tag=json:"email,omitemtpy" Get(json)="email,omitemtpy"
Cpf tag=josn:"cpf" Get(json)=""
Tel tag=json: "telefone" Get(json)=""
token tag=json:"token" Get(json)="token"
{"Nome":"Ana","Senha":"s3cr3to","email":"","Cpf":"123","Tel":"81 99999-0000"}
O go vet analisa o mesmo arquivo e aponta alguns dos problemas:
ex05-malformada/main.go:10:2: struct field tag `json:name` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
ex05-malformada/main.go:11:2: struct field tag `json:-` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
ex05-malformada/main.go:14:2: struct field tag `json: "telefone"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
ex05-malformada/main.go:15:2: struct field token has json tag but is not exported
Cada campo conta uma parte da história. Nome tem a chave json sem aspas, então Get devolve vazio e o pacote usa o nome Go, "Nome". Esse caso não vaza dado, mas muda a forma do JSON que a API entrega. O campo Senha é o mais grave. Com json:-, sem aspas, Get devolve vazio, a tag não é reconhecida como “omitir”, e a senha sai no JSON com o nome "Senha". O go vet avisa sobre a sintaxe, e o valor vai para a saída mesmo assim. Se você está lendo isto para conferir uma struct antiga, esse é o padrão a procurar primeiro.
Os outros três erros são silenciosos de outro jeito. josn (com s trocado) não gera nenhum aviso: o pacote simplesmente não procura essa chave, e o campo Cpf sai com o nome Go. Em omitemtpy, a opção mal escrita é ignorada, então o campo Email sai como "email":"" mesmo vazio, porque o omitempty de verdade não foi reconhecido. E em json: "telefone", o espaço depois dos dois-pontos quebra a sintaxe, e o campo Tel sai com o nome Go. Nenhuma dessas três linhas aparece no go vet, porque a ferramenta checa a sintaxe e não o nome das chaves nem das opções. O campo token, que não é exportado, some do JSON sem aviso no programa, mas o go vet o aponta.
Dois fatos sobre as ferramentas merecem atenção. O go vet é o único que pega a sintaxe, e o go test não roda essa verificação: a documentação do comando lista o subconjunto de verificações que ele executa, e structtag não está na lista. Ou seja, um go test verde não prova que as tags estão bem escritas. A forma de pegar o erro de verdade é um que compare a saída JSON com o texto esperado, como o da seção 12.
A figura abaixo compara a tag correta com a malformada no mesmo campo Senha:
As duas colunas seguem o mesmo roteiro de três passos, para facilitar a comparação. Na coluna verde, a tag tem aspas, o Get reconhece o "-" e o campo fica fora do JSON. Na coluna vermelha, a tag não tem aspas, o Get devolve vazio sem erro nenhum, e no último passo o nome Go vira a chave. O ponto da figura está na distância entre um erro de formatação que o programa tolera e um que vira vazamento. Quando você revisar uma struct com dado sensível, confira a tag com a mesma atenção que o tipo do campo.
9. Tags e identidade de tipo
A especificação diz que as tags participam da identidade de tipo, mas são ignoradas numa parte específica das regras: a conversão entre structs. O código abaixo prova as duas coisas com três casos:
package main
import "fmt"
type ComJSON struct {
Nome string `json:"nome"`
}
type SemTag struct {
Nome string
}
type OutraTag struct {
Nome string `json:"name"`
}
func main() {
a := ComJSON{Nome: "Ana"}
b := SemTag(a) // conversão: tags são ignoradas na comparação de structs
fmt.Println("conversão sem tag:", b.Nome)
var c OutraTag = OutraTag(a) // tags diferentes ainda convertem
fmt.Println("conversão com outra tag:", c.Nome)
var x any = a
_, ok := x.(OutraTag)
fmt.Println("asserção de tipo com tag diferente:", ok)
}
Saída real:
conversão sem tag: Ana
conversão com outra tag: Ana
asserção de tipo com tag diferente: false
A conversão funciona nos dois casos. A asserção de tipo, em contraste, responde false, porque o tipo dinâmico do valor é ComJSON, e ComJSON não é idêntico a OutraTag. Se você tentar a atribuição direta, sem conversão, o compilador recusa:
# tagsaula/ex07-atribuicao
ex07-atribuicao/main.go:12:19: cannot use ComJSON{…} (value of struct type ComJSON) as OutraTag value in variable declaration
A spec resume a regra com uma frase: as tags são ignoradas ao comparar tipos de struct para fins de conversão. Na prática, isso permite converter entre um DTO da API e um modelo interno que tenha os mesmos campos, mesmo que as tags sejam diferentes. Também significa que a conversão não avisa quando as duas tags dizem coisas diferentes sobre o mesmo campo. Esse é um detalhe a lembrar quando uma struct de API é convertida para outra de banco.
10. O custo da reflexão
Ler tags é trabalho feito em tempo de execução, e reflexão nunca é de graça. A pergunta prática é quanto custa, e a resposta depende de quantas vezes o código lê a tag. Para medir, escrevi quatro benchmarks sobre uma struct de quatro campos com tag json. O primeiro é json.Marshal inteiro. O segundo é uma escrita manual com strconv, que produz o mesmo JSON e serve de referência. O terceiro lê a tag de todos os campos a cada chamada, como faria uma função que não usa cache. O quarto lê as mesmas tags uma vez e reaproveita o resultado.
// Lê a tag de todos os campos a cada chamada, como faria uma função sem cache.
func BenchmarkLerTagsASCadaChamada(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
t := reflect.TypeOf(p)
for j := 0; j < t.NumField(); j++ {
sinkString = t.Field(j).Tag.Get("json")
}
}
}
O quarto benchmark guarda os nomes numa variável global, calculada uma vez na inicialização, e no laço apenas os lê. Antes de medir, o teste confere que a escrita manual gera exatamente a mesma saída do json.Marshal, para que a comparação seja justa.
As medições foram feitas em um MacBook Pro com Apple M1 Max, 32 GB de memória, macOS 27.0.1, com Go 1.27.2 para darwin/arm64, em 3 de novembro de 2026. Rodei o benchmark em quatro sessões: uma rodada na primeira, três na segunda e cinco na terceira e na quarta. A tabela mostra a faixa de todas as rodadas somadas.
| Operação | Faixa observada (ns/op) | Alocações |
|---|---|---|
json.Marshal da struct | 308 a 1649 | 3 por operação, 160 B |
Escrita manual com strconv | 130 a 791 | 1 por operação, 96 B |
| Ler as tags de 4 campos a cada chamada | 214 a 1661 | nenhuma |
| Ler as tags de 4 campos com cache | 4,8 a 20,5 | nenhuma |
Os valores absolutos variaram muito entre as sessões. Na quarta sessão, a máquina estava mais carregada, e o mesmo json.Marshal foi de 846 a 1649 ns, contra cerca de 300 a 400 ns nas primeiras. Por isso não tire conclusões de um número isolado. O que se manteve estável nas quatro sessões é a relação entre os casos. Ler as tags a cada chamada custou, na mesma sessão, entre cerca de 0,6 e 1 vez o custo do json.Marshal inteiro, uma ordem de grandeza comparável. Já a versão com cache ficou entre cerca de 25 e 60 vezes mais barata que a leitura por chamada, pelas medianas aproximadas de cada sessão.
Esse resultado conversa com o que a seção 6 disse sobre o cache do encoding/json. O pacote não paga a leitura de tags a cada Marshal, porque guarda os campos de cada tipo. Já um código próprio que lê tags num laço quente, como um serializador caseiro ou um mapeamento feito a cada requisição, paga essa leitura inteira a cada vez. A escolha que importa é onde fica o cache: no pacote, quando a biblioteca já faz isso por você, ou no seu código, quando você lê as tags por conta própria.
Não medi outros processadores, outros sistemas operacionais, nem outras versões do Go. A comparação entre a leitura por chamada e a com cache é o resultado mais confiável desta seção. Os números absolutos servem para dar ordem de grandeza, e nada além disso.
11. Erros comuns e boas práticas
Cada item abaixo é um erro que aparece com frequência em código real. Os primeiros parágrafos de cada subseção dizem o que acontece, e o final diz o que fazer em vez disso.
Esquecer as aspas ou pôr espaço no lugar errado
Esse é o erro mais comum e o mais barato de corrigir, e também o que mais tempo consome, porque o programa compila e roda. Uma aspa faltando faz Get devolver vazio, e o campo sai com o nome Go. Um espaço depois dos dois-pontos faz o mesmo. A saída de um teste de contrato mostra a diferença na hora, e o go vet costuma pegar esses casos. Escreva a tag sempre na forma chave:"valor", sem espaço, e rode go vet depois de editar qualquer tag.
Confiar em json:"-" sem testar a saída
A tag json:"-" é a forma certa de esconder um campo, mas só funciona se o texto estiver bem escrito. A seção 8 mostrou uma senha saindo no JSON por causa de uma aspa ausente. Não basta conferir a struct de relance. O remédio é um teste que serializa o objeto e confere se o campo sensível não aparece, como o da seção 12. Vale para senhas, chaves de API, tokens e qualquer dado que não deve sair do sistema.
Usar Get onde o vazio importa
Get não distingue uma chave ausente de uma chave com valor vazio. Se a diferença muda o comportamento, como um campo obrigatório versus um campo que a tag declara explicitamente sem valor, use Lookup e verifique o segundo valor. Quando a diferença não importa, Get é mais simples e basta.
Achar que omitempty esconde uma struct
A lista de valores vazios do omitempty não inclui struct, e a saída da seção 6 mostrou o "endereco":{} aparecendo mesmo assim. Para omitir uma struct, use omitzero, disponível desde o Go 1.24, e confirme com um teste que o JSON gerado é o esperado. Não confie na suposição de que o campo some quando está vazio.
Tag em struct embutida muda o formato
Dar tag a uma struct embutida transforma o conteúdo dela num objeto aninhado, como visto na seção 6. Os campos que antes apareciam no nível de cima passam a ficar dentro da chave escolhida, e a leitura do JSON deixa de encontrá-los onde estavam. Se a intenção é achatar o formato, deixe a struct embutida sem tag. Se a intenção é aninhar, escreva a tag e teste a leitura nas duas direções.
Typos em chave ou opção não geram erro
josn, omitemtpy e validate escrito como valdate compilam e rodam sem nenhum aviso. O go vet não cobre esses casos, porque verifica a sintaxe e não a lista de chaves conhecidas. A proteção mais eficiente é um teste de contrato por tipo que serializa e compara o JSON com o texto esperado. Um lint específico para tags pode cobrir a lista de chaves, mas não testei nenhuma ferramenta desse tipo nesta aula.
Ler tags em laço quente sem cache
Se o seu código lê tags a cada item de uma lista grande, a seção 10 mostrou o custo. A solução é a mesma que o encoding/json usa: calcule os nomes e as regras uma vez, guarde num lugar de leitura rápida e reaproveite. Medir antes de otimizar continua valendo, mas nesse caso a diferença entre 10 ns e algumas centenas de ns por campo aparece rápido.
12. Exercícios práticos
Os exercícios abaixo usam o que a aula ensinou. Tente antes de ler a resposta de referência. Cada gabarito foi rodado com a saída mostrada.
Exercício 1: produto com quatro regras de serialização
Crie uma struct Produto com estes campos: ID (int, chave id), CustoInterno (int, que nunca deve sair no JSON), Descricao (string, omitida quando vazia), Atualizado (time.Time, omitida quando zero) e PrecoCentavos (int, enviado como texto na chave preco).
Resposta de referência:
package main
import (
"encoding/json"
"fmt"
"time"
)
type Produto struct {
ID int `json:"id"`
CustoInterno int `json:"-"`
Descricao string `json:"descricao,omitempty"`
Atualizado time.Time `json:"atualizado,omitzero"`
PrecoCentavos int `json:"preco,string"`
}
func main() {
p := Produto{ID: 1, CustoInterno: 900, PrecoCentavos: 1990}
b, _ := json.Marshal(p)
fmt.Println(string(b))
p.Descricao = "Caneca"
p.Atualizado = time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC)
b, _ = json.Marshal(p)
fmt.Println(string(b))
}
Saída real:
{"id":1,"preco":"1990"}
{"id":1,"descricao":"Caneca","atualizado":"2026-11-03T00:00:00Z","preco":"1990"}
A primeira linha mostra as três omissões em ação: CustoInterno some por "-", Descricao some por omitempty, e Atualizado some por omitzero. A segunda linha mostra que as três voltam a aparecer quando têm valor.
Exercício 2: achar o campo que sumiu
Uma struct Sessao deveria serializar {"usuario":"ana","token":"abc"}, mas o teste abaixo falha. Explique a causa com base na seção 8 e corrija a tag.
Código da struct, com o defeito:
package main
type Sessao struct {
Usuario string `json:"usuario"`
Token string `json:"token,omitempty`
}
Teste de contrato:
package main
import (
"encoding/json"
"testing"
)
func TestSessaoContrato(t *testing.T) {
s := Sessao{Usuario: "ana", Token: "abc"}
b, _ := json.Marshal(s)
esperado := `{"usuario":"ana","token":"abc"}`
if string(b) != esperado {
t.Fatalf("JSON inesperado:\n obtido: %s\n esperado: %s", b, esperado)
}
}
Saída real de go test:
--- FAIL: TestSessaoContrato (0.00s)
sessao_test.go:13: JSON inesperado:
obtido: {"usuario":"ana","Token":"abc"}
esperado: {"usuario":"ana","token":"abc"}
FAIL
FAIL tagsaula/ex10-gab2 0.217s
A causa é a aspa de fechamento faltando depois de omitempty. A tag malformada faz Get devolver vazio, e o campo sai como "Token". A correção é escrever json:"token,omitempty", com a aspa. Depois dela, o teste passa. O teste é a parte que você deve guardar no projeto, porque é ele que pega o erro, e não o compilador.
Exercício 3: estender o validador manual
Acrescente ao validador da seção 7 a regra oneof, que aceita uma lista de valores separados por espaço, por exemplo validate:"oneof=pequeno medio grande". A regra deve reprovar qualquer valor fora da lista.
Resposta de referência, que substitui a função Validar e mantém a lógica anterior:
package main
import (
"fmt"
"reflect"
"strings"
)
type Pedido struct {
Porte string `validate:"required,oneof=pequeno medio grande"`
}
func Validar(v any) []string {
var erros []string
valor := reflect.ValueOf(v)
tipo := valor.Type()
for i := 0; i < tipo.NumField(); i++ {
campo := tipo.Field(i)
regras := campo.Tag.Get("validate")
if regras == "" {
continue
}
for _, regra := range strings.Split(regras, ",") {
nome, param, _ := strings.Cut(regra, "=")
fv := valor.Field(i)
switch nome {
case "required":
if fv.IsZero() {
erros = append(erros, campo.Name+": required")
}
case "oneof":
if !strings.Contains(" "+param+" ", " "+fv.String()+" ") {
erros = append(erros, fmt.Sprintf("%s: oneof=%s", campo.Name, param))
}
}
}
}
return erros
}
func main() {
fmt.Println("medio:", Validar(Pedido{Porte: "medio"}))
fmt.Println("gigante:", Validar(Pedido{Porte: "gigante"}))
}
Saída real:
medio: []
gigante: [Porte: oneof=pequeno medio grande]
A técnica de envolver a lista com espaços (" pequeno medio grande ") evita que "med" case com "medio", porque a busca exige a palavra inteira. A regra depende de a lista de valores ser separada por espaço dentro do parâmetro. Como a vírgula é o separador entre regras, valores com vírgula não podem aparecer na lista.
13. O que não foi confirmado
Confirmei nesta aula, com código rodado ou com a fonte local da toolchain, o formato da tag, o comportamento de Get, Lookup, encoding/json, go vet e go test, e a leitura de tags da biblioteca de validação na versão v10.30.6. Também confirmei que a identidade de tipo e a conversão se comportam como descrito na seção 9.
Não confirmei o comportamento do encoding/json/v2, que existe na toolchain 1.27.2 e importa sem flag, mas cuja documentação diz que o omitempty tem semântica diferente da v1. Não testei essa diferença, e a aula não a cobre.
Não confirmei o encoding/xml nem outras bibliotecas que usam tags, como ORMs e leitores de configuração. A convenção é a mesma, mas o comportamento exato de cada uma não foi verificado aqui.
Os números de desempenho valem para um único MacBook Pro com Apple M1 Max, em quatro sessões com carga variável, com Go 1.27.2. Não sei como os valores se comportam em outras máquinas, em Linux, ou em versões anteriores do Go. A relação entre a leitura por chamada e a com cache se repetiu em todas as sessões, e é a parte que considero confiável.
Não encontrei, nesta aula, uma ferramenta que confira o nome das chaves e das opções de tag. Não testei nenhum lint específico para isso.
Por fim, a versão do validador (v10.30.6) foi a mais recente que consegui ver no proxy de módulos em 3 de novembro de 2026. Ela pode ter sido superada depois dessa data, e o comportamento dos recursos que não testei, como regras próprias e tradução de mensagens, não foi conferido.
14. Glossário técnico
- Tag de struct: literal de string opcional escrito depois do tipo de um campo, que a linguagem guarda sem interpretar.
- Literal de string: texto escrito diretamente no código, entre aspas duplas ou crases.
- Raw string: literal entre crases, em que as aspas e as barras não precisam de escape.
- Reflexão: capacidade de inspecionar tipos e valores em tempo de execução, feita no Go pelo pacote
reflect. - reflect.Type: objeto que descreve um tipo, com seus campos, métodos e tamanho.
- reflect.StructField: objeto que descreve um campo de struct, com nome, tipo, tag e outras propriedades.
- reflect.StructTag: tipo baseado em string que guarda uma tag e oferece os métodos
GeteLookup. - Get: método que devolve o valor de uma chave da tag, ou vazio quando a chave não existe ou não é reconhecida.
- Lookup: método que devolve o valor e um booleano
ok, que diz se a chave estava escrita na tag. - Identidade de tipo: critério que o compilador usa para decidir se dois tipos são o mesmo tipo. Tags participam dele.
- omitempty: opção do
encoding/jsonque omite campos com valor vazio, como zero, string vazia, nil, ou slice e map vazios. - omitzero: opção do
encoding/json, disponível desde o Go 1.24, que omite campos com o valor zero do próprio tipo. - Marshal: operação que transforma um valor Go em JSON.
- Unmarshal: operação que lê um JSON e preenche um valor Go.
- go vet: ferramenta que analisa o código em busca de padrões suspeitos, entre eles a sintaxe das tags.
- Teste de contrato: teste que compara a saída de uma função, como um JSON, com um texto esperado.
15. Fontes consultadas
As fontes abaixo foram consultadas em 3 de novembro de 2026. A maior parte das verificações foi feita na documentação e no código-fonte da toolchain Go 1.27.2 instalada localmente, com go doc e a leitura direta da spec e do código do pacote.
- Especificação da linguagem Go, seção sobre tipos de struct e tags, lida em
doc/go_spec.htmlda toolchain 1.27.2. Versão pública em go.dev/ref/spec. - Documentação do pacote
reflectparaStructTag,StructTag.GeteStructTag.Lookup, viago docna toolchain 1.27.2. Versão pública em pkg.go.dev/reflect. - Documentação do pacote
encoding/json, incluindo as regras deomitempty,stringe a correspondência de nomes, viago docna toolchain 1.27.2. Versão pública em pkg.go.dev/encoding/json. - Código-fonte da toolchain 1.27.2:
src/reflect/type.go(funçãoLookup, comstrconv.Unquote),src/encoding/json/encode.go(cachefieldCachee a opçãoomitzero) esrc/encoding/json/decode.go(preferência por correspondência exata). - Documentação do comando
go testsobre o subconjunto de verificações dogo vet, viago help testna toolchain 1.27.2. - Notas de lançamento do Go 1.24 (go.dev/doc/go1.24), que citam a opção
omitzeronoencoding/jsone informam o lançamento em fevereiro de 2025. Consultadas via WebFetch. - Projeto go-playground/validator, versão v10.30.6, obtida do proxy de módulos (proxy.golang.org), com data de publicação de 29/10/2026 segundo o próprio proxy. Versão pública em github.com/go-playground/validator.
Relacionados
- Go
- Structs
- Trabalhando com JSON
- SQLX
- GORM na Prática
- Conversão de tipos
- Testes Unitários com testing
- Trilha de Go