Home
Aug 5, 2026 - 17 MIN READ
Nil map ou map vazio? Zero value, ordem de iteração e concorrência em Go

Nil map ou map vazio? Zero value, ordem de iteração e concorrência em Go

Como o zero value de um map se comporta em Go, por que a ordem de iteração é aleatória de propósito, e como sincronizar acesso concorrente sem escolher a ferramenta errada.

No artigo sobre zero values e pointer semantics eu mencionei de passagem que nem todo zero value é útil de imediato, e que o map é o exemplo clássico: seu zero value é nil, e escrever nele causa panic. Fechei o assunto em duas frases. Ele merecia mais espaço, e é este artigo aqui.

Vamos começar pelo bug, como de costume. Um rate limiter simples, guardado por cliente:

type RateLimiter struct {
    hits map[string]int
}

func (r *RateLimiter) Allow(client string) bool {
    r.hits[client]++
    return r.hits[client] <= 100
}

Nada aqui parece perigoso: um struct, um método, um map como campo. O problema aparece no uso mais óbvio possível:

var limiter RateLimiter
limiter.Allow("client-1")
panic: assignment to entry in nil map

var limiter RateLimiter é exatamente o padrão que o artigo sobre zero values elogiou: nenhuma inicialização, struct pronta para uso. Só que "pronta para uso" tinha uma exceção que eu não expliquei direito na época, e ela mora no campo hits.

Tudo aqui foi verificado com Go 1.26, rodando os exemplos localmente e lendo o código-fonte do runtime (internal/runtime/maps). Onde o comportamento já mudou entre versões, existe uma seção específica sobre isso mais adiante.

A proposta deste artigo é destrinchar três coisas que costumam ser tratadas como folclore, mas têm mecanismo por trás: o que o zero value de um map permite e o que ele proíbe, por que rodar duas vezes sobre o mesmo map te dá ordens diferentes, e o que realmente acontece quando duas goroutines mexem no mesmo map ao mesmo tempo.

Por baixo, uma hash table

Um map em Go é, por definição, uma coleção não ordenada:

"Um map é um grupo não ordenado de elementos de um tipo, chamado tipo do elemento, indexado por um conjunto de chaves únicas de outro tipo, chamado tipo da chave. O valor de um map não inicializado é nil."

The Go Programming Language Specification, Map types

"Não ordenado" ali não é um detalhe cosmético, é a raiz da Parte 2 deste artigo. Por baixo, a implementação é uma hash table, e desde o Go 1.24 essa hash table é uma Swiss Table:

"Diversas melhorias de performance no runtime reduziram o overhead de CPU em 2–3% em média, em um conjunto de benchmarks representativos (...) Essas melhorias incluem uma nova implementação do map embutido baseada em Swiss Tables (...)"

Go 1.24 Release Notes

A troca é reversível em tempo de build (GOEXPERIMENT=noswissmap), o que já é uma boa pista de que isso é implementação, não contrato: nenhum programa correto deveria depender de qual hash table roda por baixo.

Sobre o tipo da chave, a spec exige que ele seja comparável com ==, e isso já apareceu no artigo sobre arrays e slices: é por isso que um array pode ser chave de map e um slice não pode. Não vou repetir aquela discussão aqui — vale a pena ler se você pulou.

Parte 1: o zero value que engana

O zero value de um map é nil, e a spec é direta sobre o que isso significa:

"Um map nil é equivalente a um map vazio, exceto que nenhum elemento pode ser adicionado."

The Go Programming Language Specification, Map types

"Equivalente a um map vazio, exceto" é uma frase que vale a pena testar sem confiar de olhos fechados:

var m map[string]int

fmt.Println(m["missing"]) // 0, sem panic
fmt.Println(len(m))       // 0
v, ok := m["missing"]
fmt.Println(v, ok)        // 0 false
for range m {
    // nunca entra aqui
}
delete(m, "missing") // no-op, sem panic
read from nil map: 0
len(nil map): 0
comma-ok on nil map: 0 false
range over nil map iterations: 0
delete on nil map: survived

Leitura, len, range, comma-ok e delete funcionam todos normalmente. A única operação que quebra é a escrita:

var m map[string]int
m["missing"] = 1
panic: assignment to entry in nil map

Essa é a exceção da spec, e é ela que pegou o RateLimiter da introdução: r.hits[client]++ é leitura seguida de escrita, e a escrita é o que derruba o programa.

Isso é diferente de um slicenil, que aceita append numa boa — ponto que também já apareceu no artigo de zero values. A assimetria existe porque append sempre pode criar um array novo por baixo, e o compilador sabe fazer isso. Escrever num mapnil exigiria o mesmo truque, e o time do Go optou por não fazer alocação implícita ali; a spec faz questão de deixar isso explícito como exceção, não como acidente.

A correção de verdade é inicializar o campo no construtor:

func NewRateLimiter() *RateLimiter {
    return &RateLimiter{hits: make(map[string]int)}
}

Estruturas que vêm de fora, porém, às vezes te dão o make de graça. Um map que é campo de destino de um json.Unmarshal é alocado automaticamente se a chave existir no JSON:

type Config struct {
    Tags map[string]string
}

var c Config
json.Unmarshal([]byte(`{"Tags":{"env":"prod"}}`), &c)
fmt.Println(c.Tags == nil) // false

var c2 Config
json.Unmarshal([]byte(`{}`), &c2)
fmt.Println(c2.Tags == nil) // true

O que explica um bug clássico de configuração: o map funciona em todo ambiente que define a chave Tags e panica especificamente naquele onde ela foi omitida. Não é um bug de encoding/json, é o comportamento documentado da spec aparecendo num lugar inesperado.

clear(m), disponível desde o Go 1.21, esvazia um map sem torná-lo nil: depois de clear(m), len(m) é 0, mas m == nil continua false. Se o seu código depois checa m == nil para decidir se precisa inicializar, clear não serve para isso — é delete de tudo, não é "resetar para o zero value".

Parte 2: a ordem de iteração é aleatória de propósito

Aqui está uma demonstração simples: a mesma função, chamada cinco vezes seguidas, sobre o mesmo map, no mesmo processo.

func keys(m map[int]bool) []int {
    out := make([]int, 0, len(m))
    for k := range m {
        out = append(out, k)
    }
    return out
}
[4 6 7 8 9 2 5 0 1 3]
[1 3 4 6 7 8 9 2 5 0]
[4 6 7 8 9 2 5 0 1 3]
[0 1 3 4 6 7 8 9 2 5]
[2 5 0 1 3 4 6 7 8 9]

Cinco chamadas, quatro ordens diferentes, mesmo map, mesmo processo, mesmos dados. A spec é explícita sobre isso desde a primeira versão pública da linguagem:

"A ordem de iteração sobre maps não é especificada e não há garantia de que seja a mesma de uma iteração para a outra. Se uma entrada do map que ainda não foi alcançada é removida durante a iteração, o valor de iteração correspondente não será produzido. Se uma entrada é criada durante a iteração, ela pode ou não ser produzida durante aquela iteração. (...) Se o map é nil, o número de iterações é 0."

The Go Programming Language Specification, For statements

Essa garantia (ou melhor, a ausência dela) já era documentada no Go Blog em 2013, então não é novidade trazida pela Swiss Table de 2024: é uma escolha de design antiga.

Repare em algo nas cinco sequências acima: elas não são embaralhadas de forma independente, são rotações umas das outras. A segunda começa exatamente onde a primeira tinha "1" e continua na mesma ordem cíclica dali em diante. Isso não é coincidência, é o mecanismo: cada range sorteia só o ponto de partida, não a ordem relativa dos elementos.

A struct do iterador declara dois campos para isso:

// Randomize iteration order by starting iteration at a random slot
// offset. The offset into the directory uses a separate offset, as it
// must adjust when the directory grows.
entryOffset uint64
dirOffset   uint64

E o método que inicializa um novo iterador os preenche assim, uma vez por chamada de range:

it.entryOffset = rand()
it.dirOffset   = rand()

internal/runtime/maps/table.go

Cada chamada a range cria um iterador novo, e esse iterador sorteia dois deslocamentos: um para a posição inicial dentro do grupo de slots, outro para o índice inicial no diretório de grupos. O layout interno dos dados não muda entre uma chamada e outra, só o ponto em que a varredura começa — e é exatamente por isso que a sequência observada é uma rotação, não uma permutação aleatória completa.

Quando você precisa de ordem, peça ordem

A saída correta para "eu preciso imprimir isso sempre na mesma ordem" não é torcer para o map cooperar, é ordenar as chaves explicitamente. Desde o Go 1.23, o pacote maps devolve um iterador, e o pacote slices sabe consumi-lo:

totals := map[string]int{"checkout": 42, "home": 108, "login": 17}

keys := slices.Sorted(maps.Keys(totals))
for _, k := range keys {
    fmt.Printf("%s: %d\n", k, totals[k])
}
checkout: 42
home: 108
login: 17

Um lugar em que isso já acontece por baixo dos panos é encoding/json. Serializar um map não devolve uma ordem aleatória:

"Valores de map são codificados como objetos JSON. (...) As chaves do map são ordenadas e usadas como chaves do objeto JSON (...)"

Documentação do pacote encoding/json, Marshal

m := map[string]int{"zebra": 1, "apple": 2, "mango": 3}
b, _ := json.Marshal(m)
fmt.Println(string(b))
{"apple":2,"mango":3,"zebra":1}

Rodei esse Marshal três vezes no mesmo processo: as três saídas vieram idênticas, em ordem alfabética. encoding/json ordena as chaves para você antes de serializar, exatamente para que o resultado seja determinístico apesar da iteração por baixo não ser.

Essa garantia é do pacote encoding/jsonv1, o que você importa com "encoding/json". Existe um pacote experimental encoding/json/v2 (atrás da flag GOEXPERIMENT=jsonv2, ainda não habilitado por padrão no Go 1.26) cuja documentação declara explicitamente o oposto por padrão: ordem não determinística, com uma opção jsonv2.Deterministic para religar o comportamento antigo. Se esse pacote se tornar padrão no futuro, vale reconferir esse ponto.

Parte 3: concorrência — o que é seguro e o que não é

A pergunta que o time do Go já respondeu publicamente, e vale citar por inteiro porque a resposta é mais matizada do que "use um mutex":

"Depois de uma longa discussão, decidiu-se que o uso típico de maps não exigia acesso seguro a partir de múltiplas goroutines, e nos casos em que exigia, o map provavelmente fazia parte de uma estrutura de dados ou computação maior que já estava sincronizada. (...) O acesso a um map só é inseguro quando há atualizações ocorrendo. Enquanto todas as goroutines estiverem apenas lendo — buscando elementos no map, inclusive iterando com um for range — e não alterando o map por atribuição ou remoção, é seguro acessá-lo concorrentemente sem sincronização."

Go FAQ, Why are map operations not defined to be atomic?

Ou seja: leituras concorrentes são seguras, desde que não exista um escritor. No momento em que existe uma escrita concorrente com qualquer outro acesso (leitura ou escrita), a garantia acaba.

Um cenário realista: um contador de acessos por rota, incrementado a cada request e lido por um endpoint de métricas.

hits := map[string]int{"home": 0, "login": 0, "checkout": 0}

go func() { // simula requests incrementando
    for i := 0; i < 1_000_000; i++ {
        hits["home"]++
    }
}()

go func() { // simula o endpoint de métricas lendo
    for i := 0; i < 1_000_000; i++ {
        _ = hits["home"]
    }
}()
fatal error: concurrent map read and map write

Rodei esse programa várias vezes, sem a flag -race, e ele derrubou o processo de forma consistente com essa mensagem. Se as duas goroutines escrevem (em vez de uma ler e outra escrever), a mensagem muda:

fatal error: concurrent map writes
Tentei envolver esse programa num recover(). Não adiantou — o processo derruba do mesmo jeito. A razão é que isso não é um panic: é um fatal error, gerado pela função interna fatal() do runtime, que existe justamente para condições que o runtime considera não seguras de continuar, mesmo que alguém tenha um recover no meio do caminho. panic você pode interceptar; isso, não.

O runtime detecta esses casos com uma verificação leve, presente desde o Go 1.6:

"O runtime passou a ter uma detecção leve e best-effort de uso concorrente incorreto de maps. Como sempre, se uma goroutine está escrevendo em um map, nenhuma outra goroutine deveria estar lendo ou escrevendo nesse map concorrentemente. Se o runtime detecta essa condição, ele imprime um diagnóstico e derruba o programa."

Go 1.6 Release Notes

Por dentro, a implementação mantém um campo (writing, em internal/runtime/maps/map.go) que é alternado a cada escrita; se uma leitura ou outra escrita encontra esse campo já ligado, ela chama fatal. É uma verificação leve de propósito — best-effort, nas palavras da release note — não uma trava. Ela pega grande parte dos casos reais, mas não é uma prova de ausência de corrida: código que passa sem crashar não está necessariamente correto, só não teve azar naquela execução. Para uma detecção confiável, a ferramenta certa é o detector de corrida: go test -race, go run -race.

Duas ferramentas, dois problemas diferentes

A resposta para "qual mecanismo eu uso" não é sempre a mesma. A documentação do sync.Map é direta sobre isso:

"O tipo Map é especializado. A maior parte do código deveria usar um map comum do Go, com locking separado, para melhor segurança de tipos e para facilitar a manutenção de outras invariantes junto com o conteúdo do map."

Documentação do pacote sync, Map

O caso padrão, então, é map comum protegido por sync.Mutex ou sync.RWMutex:

type Cache struct {
    mu sync.RWMutex
    m  map[string]int
}

func (c *Cache) Get(k string) (int, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache) Set(k string, v int) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

sync.Map existe para dois padrões de acesso específicos, também descritos na documentação:

"quando a entrada de uma dada chave é escrita apenas uma vez mas lida muitas vezes, como em caches que só crescem" (...) "quando múltiplas goroutines leem, escrevem e sobrescrevem entradas para conjuntos disjuntos de chaves (...) Nesses dois casos, o uso de um Map pode reduzir significativamente a contenção de locks em comparação com um map do Go pareado com um Mutex ou RWMutex separado."

Documentação do pacote sync, Map

Medindo exatamente o primeiro cenário — popular o cache uma vez, depois bombardear com leituras concorrentes de 8 goroutines:

BenchmarkMutexCache-8   23049524    52.67 ns/op    0 B/op   0 allocs/op
BenchmarkSyncMap-8     185469519     7.193 ns/op    0 B/op   0 allocs/op

sync.Map é cerca de 7× mais rápido aqui, e a razão é estrutural. Desde o Go 1.24, sync.Map não é mais implementado com o desenho clássico de "read map somado a dirty map protegida por Mutex": por baixo ele usa uma hash-trie concorrente (internal/sync.HashTrieMap), que a documentação descreve como "desenhada para leituras frequentes, mas com desempenho razoável também para escritas e remoções". A release note é direta sobre o ganho:

"A implementação de sync.Map mudou, melhorando a performance, particularmente para modificações no map. Por exemplo, modificações em conjuntos disjuntos de chaves têm muito menos chance de disputa em maps grandes, e não existe mais nenhum tempo de aquecimento necessário para atingir baixa contenção em leituras do map."

Go 1.24 Release Notes

Fora do padrão "escreve uma vez, lê muitas vezes" (ou do padrão de chaves disjuntas), essa vantagem desaparece ou inverte, porque sync.Map ainda paga um custo de indireção que o map+Mutex não tem. É por isso que a documentação não recomenda sync.Map como padrão: ela ajuda exatamente dois formatos de acesso, e atrapalha fora deles.

Limitações, exceções e armadilhas

Você não pode atribuir a um campo de struct dentro de um map diretamente.

type Order struct{ Status string }

m := map[string]Order{"a": {Status: "pending"}}
m["a"].Status = "paid"
cannot assign to struct field m["a"].Status in map

O motivo é que m["a"] não é endereçável: cada acesso de leitura devolve uma cópia do valor guardado, não um ponteiro para dentro do map. A saída é ler, alterar a cópia e regravar (o := m["a"]; o.Status = "paid"; m["a"] = o), ou guardar map[string]*Order desde o início.

Maps não encolhem sozinhos. Depois de remover 2 milhões de entradas de um map[int][32]byte, seja com delete em loop, seja com clear(m), o heap continuou no mesmo patamar de antes da remoção (192 MiB, medido com runtime.ReadMemStats, antes e depois). O map mantém a tabela interna no tamanho do pico de uso; ela só é liberada quando o map inteiro deixa de ser referenciado (m = make(map[K]V) ou m = nil) e o GC recolhe a estrutura antiga. Se um map de vida longa passa por um pico e depois fica pequeno, considerar recriá-lo é uma opção real, não paranoia.

A detecção de concorrência é probabilística, o -race não. Um programa que roda sem fatal error várias vezes seguidas não está provado correto — a chance de duas goroutines colidirem no exato instante que o runtime verifica depende de timing. CI que quer pegar isso de verdade precisa rodar com -race, não só observar ausência de crash.

nil map e map vazio não são a mesma coisa na borda com JSON, no mesmo espírito do que já valia para slices:

var nilMap map[string]int
emptyMap := map[string]int{}

json.Marshal(nilMap)   // null
json.Marshal(emptyMap) // {}

Se a sua API devolve um objeto e o contrato promete {} em vez de null, garanta que o map foi inicializado antes de serializar.

sync.Map não é gratuito fora do seu nicho. A própria documentação cita free lists de objetos de vida curta como um exemplo de uso ruim: "o overhead não se amortiza bem nesse cenário". Fora dos dois padrões documentados, comece com map + Mutex/RWMutex e só troque se um profile mostrar contenção de lock que sync.Map resolveria.

Mudanças entre versões

VersãoO que mudou
Go 1.6Detecção leve e best-effort de uso concorrente incorreto de maps, com fatal error diagnosticando a condição.
Go 1.21Builtin clear (esvazia um map sem realocar e sem torná-lo nil); pacotes slices e maps chegam à biblioteca padrão.
Go 1.23Iteradores range-over-func; maps.Keys, maps.Values e maps.All passam a devolver iter.Seq; slices.Sorted e slices.Collect chegam para consumi-los.
Go 1.24Nova implementação de map baseada em Swiss Tables, reversível via GOEXPERIMENT=noswissmap. Redução média de 2–3% no overhead de CPU do runtime; nenhuma mudança de semântica. sync.Map também foi reimplementado sobre uma hash-trie concorrente, reversível via GOEXPERIMENT=nosynchashtriemap.
Go 1.25+ (experimental)Pacote encoding/json/v2, atrás de GOEXPERIMENT=jsonv2, inverte o padrão de serialização de maps para ordem não determinística, com a opção jsonv2.Deterministic para restaurar o comportamento de v1. Ainda não é o encoding/json padrão.

Juntando tudo: o caminho mental

Se eu tivesse que resumir o raciocínio em uma sequência de perguntas:

  1. Esse map foi inicializado antes da primeira escrita? Zero value de map é nil, e nil aceita leitura, len, range e delete — só não aceita escrita. Se o campo vem de um struct literal parcial ou de um zero value implícito, confira.
  2. Meu código depende da ordem em que o range devolve os elementos? Se depende, isso é um bug latente, não uma coincidência que ainda não apareceu. Ordene as chaves explicitamente (slices.Sorted(maps.Keys(m))) ou deixe o encoding/json fazer isso por você na borda de serialização.
  3. Esse map é acessado por mais de uma goroutine? Se só há leitura, nenhuma sincronização é necessária. No instante em que existe uma escrita concorrente, escolha entre map + Mutex/RWMutex (o caso geral) ou sync.Map (escrita única com muitas leituras, ou chaves disjuntas por goroutine).
  4. Meus testes de concorrência rodam com -race? Se não rodam, "nunca vi crashar" não significa "está correto".
  5. Esse map já teve um pico de tamanho e hoje está pequeno? Se teve, ele ainda está segurando a memória do pico. Recriar é uma opção legítima.

O fio que conecta os três assuntos deste artigo é o mesmo que já apareceu nos anteriores: Go tenta te dar previsibilidade de graça (zero value determinístico, verificação de corrida embutida no runtime) e, no ponto exato em que a previsibilidade custaria caro demais para o caso comum, ela para. A ordem de iteração podia ser estável e não é, de propósito, porque estabilidade acidental vira dependência de manutenção. A escrita concorrente podia ser silenciosamente permitida e não é, porque silenciosa é pior que crash. Entender onde essa linha foi traçada é entender o map.

Referências

Copyright © 2026 Jairo Blatt