Unmarshal needs a destination
import "encoding/json"
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags,omitempty"`
}
var u User
if err := json.Unmarshal([]byte(s), &u); err != nil {
return fmt.Errorf("parse user: %w", err)
}
b, err := json.Marshal(u) // back to []byte
out := string(b)Go has no dynamic JSONObject type, so unlike Python or JavaScript there is no "parse and see what you get". You declare the shape first and the decoder fills it in. That is more typing up front and considerably fewer surprises later.
Struct tags
The backtick tag maps a JSON key to a Go field. Without it, matching falls back to a case-insensitive comparison of the field name — which works for name but not foruser_id. Two modifiers matter: omitempty drops zero values when marshalling, and - excludes a field from JSON entirely, which is how you keep a password hash out of an API response.
When the shape is unknown
var v map[string]interface{}
json.Unmarshal([]byte(s), &v)
name, ok := v["name"].(string) // assert every access
id := int(v["id"].(float64)) // numbers arrive as float64
// keep a sub-document unparsed until you know its type
type Envelope struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data"`
}json.RawMessage is the idiomatic answer to polymorphic payloads: read the discriminator field first, then unmarshal the deferred bytes into the right concrete type. It avoids both the double parse and the type assertions.
Traps worth knowing
- Unexported fields are invisible. A lowercase field name is skipped in both directions, silently, with no error.
- float64 for every number. Unmarshalling into
interfaceturns an int64 ID into a float and loses precision past 2^53. Use a typed struct field. - omitempty cannot see the difference between zero and absent. A
falseor0disappears from the output. Use a pointer field when the distinction matters. - Unmarshal ignores unknown keys. That is usually right for API clients; when it is not, use a
DecoderwithDisallowUnknownFields(). - Marshal escapes HTML by default, turning
<into\u003c. Disable it withEncoder.SetEscapeHTML(false).
Streams beat strings
Unmarshal needs the entire document in memory before it starts. For an HTTP handler that is usually fine, but for a large file or a long-lived response it is wasteful.json.NewDecoder(r).Decode(&v) reads straight from anyio.Reader, and json.NewEncoder(w).Encode(v) writes straight to anio.Writer — no intermediate byte slice, and a lower memory ceiling that does not grow with the payload.
The decoder also handles streams of concatenated documents, which is what makes it the right tool for JSON Lines: call Decode in a loop until it returns io.EOF and each call consumes exactly one value. Do add http.MaxBytesReader around request bodies before decoding, or a client can hand you a payload large enough to exhaust the process.
Custom marshalling
When a type does not map cleanly onto JSON — a time.Duration that should be written as "30s", an enum stored as an int but exposed as a name — implementMarshalJSON and UnmarshalJSON on the type itself. The encoder checks for those interfaces before falling back to reflection, so the conversion lives with the type rather than being repeated at every call site. Define them on a named type rather than a struct field, and keep the two implementations symmetrical: a round trip that does not return the original value is a bug that will surface as data drift long after you wrote it.
Naming, briefly
Golang convert string to JSON and convert string to JSON golang both meanjson.Unmarshal; golang convert JSON to string and convert JSON to string golang both mean json.Marshal followed by a string() conversion. Go has exactly two entry points and no third-party parser is required for either direction.