encoding/json nằm trong thư viện chuẩn và bạn sẽ dùng nó hằng ngày. Bài này về bốn chỗ nó im lặng làm điều bạn không mong đợi.

Tag và quy tắc cơ bản

type Nguoi struct {
	Ten   string    `json:"ten"`
	Tuoi  int       `json:"tuoi,omitempty"`
	Bimat string    `json:"-"`
	rieng string
	Luc   time.Time `json:"luc"`
}
  {"ten":"Minh","luc":"2026-08-13T10:00:00Z"}

Ba trường biến mất, mỗi cái vì một lý do khác nhau:

Tuoiomitempty và giá trị là zero. Bimat vì tag json:"-". riengviết thường — bài 19 đã nói: phản chiếu không thấy trường không xuất khẩu.

Cái thứ ba là bẫy số một với người mới: struct trông đúng, JSON ra thiếu trường, không có lỗi nào.

time.Time được mã hoá thành RFC 3339 tự động, và đọc lại được — đây là một trong số ít kiểu thư viện chuẩn có xử lý JSON sẵn.

Bẫy một: trường lạ bị bỏ im lặng

  {"ten":"Lan","khong_co":123}  ->  err=<nil>

Go bỏ qua trường không có trong struct, không báo gì. Với API bên ngoài thì đó là hành vi đúng — bên kia thêm trường mới không nên làm hỏng bạn.

Nhưng với cấu hình nội bộ thì nó nguy hiểm: gõ nhầm timeout thành timout và bạn im lặng dùng giá trị mặc định.

dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
  json: unknown field "khong_co"

Bật nó cho mọi tệp cấu hình. Đây là cùng bài học với FAIL_ON_UNKNOWN_PROPERTIES của Jackson ở bài 60 sê-ri Java.

Bẫy hai: số thành float64

  m["n"] kiểu float64 giá trị 42

Unmarshal vào map[string]any hoặc any thì mọi số thành float64 — kể cả số nguyên. Ép về int phải qua int(m["n"].(float64)).

Và với số lớn:

  {"id": 12345678901234567890}  ->  1.2345678901234567e+19

Mất độ chính xác. float64 chỉ giữ chính xác được 53 bit, nên ID kiểu snowflake hay số tiền tính bằng đơn vị nhỏ nhất sẽ sai.

Hai cách chữa:

dec.UseNumber()          // số thành json.Number, giữ nguyên chuỗi

hoặc — cách tôi khuyên — định nghĩa struct thay vì dùng any. Với Id int64 trong struct, Go đọc đúng.

Đây là lý do nên tránh map[string]any cho dữ liệu bạn biết cấu trúc.

Bẫy ba: không phân biệt "thiếu" với "bằng 0"

  {}       ->  N=<nil>
  {"n":0}  ->  N=0

Với trường int thường, cả hai trường hợp đều cho 0 và bạn không biết người gửi có ý gì. Với API PATCH, đây là khác biệt giữa "đừng đổi" và "đặt về 0".

Dùng con trỏ:

type Cau struct{ N *int `json:"n"` }

nil nghĩa là không có trong JSON; trỏ tới 0 nghĩa là có và bằng 0.

Cái giá là mã dùng phải kiểm nil khắp nơi, nên chỉ dùng ở những trường thật sự cần phân biệt.

Bẫy bốn: omitempty không làm điều bạn nghĩ

omitempty bỏ trường khi giá trị là zero value: 0, "", false, nil, slice/map rỗng.

Nghĩa là Tuoi: 0 biến mất — dù đó có thể là tuổi thật (trẻ sơ sinh) hoặc số dư thật.

Và nó không bỏ struct rỗng: Diem{} vẫn được ghi ra thành {"x":0,"y":0}. Muốn bỏ thì phải dùng con trỏ.

Với Go 1.24 có thêm omitzero phân biệt rõ hơn, nhưng trên 1.23 thì omitempty là thứ bạn có.

Streaming: Decoder và Encoder

// đọc từ HTTP request
var p Nguoi
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { ... }

// ghi ra HTTP response
json.NewEncoder(w).Encode(p)

Dùng dạng này thay vì ReadAll rồi Unmarshal — nó không nạp cả thân request vào bộ nhớ.

Và luôn giới hạn kích thước với dữ liệu từ ngoài:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1MB

Không có nó, một request 10GB làm hết bộ nhớ máy chủ.

Tuỳ biến bằng MarshalJSON

type Tien int64      // lưu bằng xu

func (t Tien) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`"%d.%02d"`, t/100, t%100)), nil
}

Cài json.Marshalerjson.Unmarshaler để kiểm soát hoàn toàn. Hữu ích cho tiền, ngày tháng định dạng riêng, enum thành chuỗi.

Cẩn thận đệ quy vô hạn: gọi json.Marshal(t) bên trong MarshalJSON của chính t. Cách chuẩn là định nghĩa kiểu phụ:

type alias Tien
json.Marshal(alias(t))     // alias KHÔNG có method MarshalJSON

Hiệu năng

encoding/json dùng phản chiếu và không phải nhanh nhất. Với dịch vụ throughput cao, các thư viện như json-iterator hay sonic nhanh hơn vài lần.

Nhưng đừng đổi trước khi đo — với đa số dịch vụ, JSON không phải chỗ nghẽn, và thư viện chuẩn ổn định hơn.

Thử ba mươi giây

type T struct {
	Ten string
	tuoi int
}
j, _ := json.Marshal(T{Ten: "a", tuoi: 30})
fmt.Println(string(j))

In ra {"Ten":"a"}. Trường tuoi biến mất và không có lỗi.

Ba mươi giây đó là lý do quy tắc chữ hoa ở bài 19 đáng nhớ.

Ngày mai: net/http — dựng máy chủ trong mười dòng.