JavaRush /Courses /Go SELF /json.Number and json.RawMessage — parsing JSON

json.Number and json.RawMessage — parsing JSON

Go SELF
Level 46, Lesson 2
Available

1. Numbers in JSON and json.Number

If you’ve ever thought, “A number is a number, what’s so hard about it,” then congratulations — you’re a normal person. But JSON is a bit sneaky right here. In JSON there is only one numeric type: number, with no separation into int, int64, float64, “money with two decimal places,” and “a task ID that must not be rounded, because it’s an ID, not a temperature in a weather forecast.”

It gets especially fun when we decode JSON not into a struct, but into a “dynamic” model (any, map[string]any). By default, encoding/json puts numbers into float64, and with small values you might not even notice. And then a bigger ID appears in your app, and “rounding magic” begins — the kind that makes you want to hide in fmt.Println("I'm tired").

Let’s look at a simple example: decode JSON into map[string]any and print the types.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var m map[string]any
	_ = json.Unmarshal([]byte(`{"id": 10, "ratio": 0.25}`), &m)

	fmt.Printf("id: %v (%T)\n", m["id"], m["id"])       // id: 10 (float64)
	fmt.Printf("ratio: %v (%T)\n", m["ratio"], m["ratio"]) // ratio: 0.25 (float64)
}

Notice: 10 suddenly became float64. This isn’t a bug, it’s a design choice: if you ask for “dynamic” parsing, the library picks default types.

Why is this a problem specifically for us (and especially for our learning task-manager app tasker)? Because IDs, counters, version numbers, amounts in cents — these are usually integers, where any rounding turns into “the wrong object” or “the wrong money.” And money, as we all know, loves precision. And accounting.

Decoder.UseNumber(): turn on “don’t touch my numbers” mode

When we realize the input is “dynamic” but we still want to control numbers, encoding/json has a special mode: Decoder.UseNumber(). It tells the decoder roughly this: “If you see a number and you don’t know what to type it as, don’t turn it into float64. Keep it as json.Number.”

Important: UseNumber() makes sense specifically when you decode into any, map[string]any, []any, and similar dynamic containers. If you decode into a struct with fields of type int, int64, float64, then you already have strict typing, and the library will already try to parse the number into the required type.

A small example using io.Reader: this is closer to real life, because we often read JSON from a file, the network, or stdin, not from a ready-made []byte.

package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

func main() {
	dec := json.NewDecoder(strings.NewReader(`{"id": 10}`))
	dec.UseNumber()

	var m map[string]any
	_ = dec.Decode(&m)

	fmt.Printf("id: %v (%T)\n", m["id"], m["id"]) // id: 10 (json.Number)
}

Now id is a json.Number. And that’s good: it didn’t “drift” into float64, it stayed a number “as text,” which we will later explicitly convert into int64 or float64 and handle the possible error.

To lock in the difference, here’s a compact table:

How we decode What happens to the JSON number 123
json.Unmarshal into map[string]any
float64(123)
Decoder + UseNumber() into map[string]any
json.Number("123")
json.Unmarshal into struct{ ID int64 } int64(123) (or an error if it’s not an integer / too large)

json.Number: parse deliberately and don’t take data on faith

When a number arrives as json.Number, it behaves like “a number in textual form” plus a set of conversion methods. This is very Go-like: if you want a type, convert explicitly; if it fails, get an error and decide what to do.

Let’s break down the basic pattern: take a value from a map, check its type, convert it.

package main

import (
	"encoding/json"
	"fmt"
)

func readID(m map[string]any) (int64, error) {
	v, ok := m["id"]
	if !ok {
		return 0, fmt.Errorf("missing field id")
	}

	n, ok := v.(json.Number)
	if !ok {
		return 0, fmt.Errorf("field id is not a number")
	}

	id, err := n.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	return id, nil
}

Two things matter here.

First: json.Number.Int64() can return an error. For example, if we receive {"id": 1.5} or {"id": "abc"} (yes, “a number as a string” happens too, especially when the data was produced in a hurry). And an error is normal: you’ve simply learned that the input does not match the contract.

Second: this is exactly the contract-checking moment. Meaning the place where we turn “some JSON” into “data for our program.” The earlier you do this (at the boundary), the fewer weird things will leak further downstream.

A slightly more practical helper for our tasker: let it read the ID as an int (so it’s convenient to work with in code), but still store and return an error if the number doesn’t fit or isn’t an integer.

package main

import (
	"encoding/json"
	"fmt"
)

func parseIntID(v any) (int, error) {
	n, ok := v.(json.Number)
	if !ok {
		return 0, fmt.Errorf("id must be number")
	}

	id64, err := n.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	if id64 <= 0 {
		return 0, fmt.Errorf("id must be positive")
	}
	return int(id64), nil
}

Yes, there’s one more nuance: converting int64 → int is potentially unsafe on 32-bit systems, but for learning purposes we keep that in mind as “we’ll be more careful later,” and for now assume our int is large enough. The main point is that you got the idea: json.Number forces you to do the conversion explicitly, and that saves you from “silent rounding.”

Mini-feature for tasker: a dynamic command and safe numbers

Now we’ll assemble a small piece of functionality that looks very real-world. Imagine our tasker can accept commands in JSON from stdin (or from a file): for example, “mark a task as done.” We want to support a format where the command can evolve, while we currently read only the fields we need.

The idea is: read into map[string]any, enable UseNumber, then manually extract the required fields and validate them.

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

func readCommand() (map[string]any, error) {
	dec := json.NewDecoder(os.Stdin)
	dec.UseNumber()

	var m map[string]any
	if err := dec.Decode(&m); err != nil {
		return nil, fmt.Errorf("decode command: %w", err)
	}
	return m, nil
}

Now imagine the command:

{"op":"done","id":10}

Parsing:

package main

import "fmt"

func handleDone(cmd map[string]any) error {
	op, _ := cmd["op"].(string)
	if op != "done" {
		return fmt.Errorf("unexpected op %q", op)
	}

	id, err := parseIntID(cmd["id"])
	if err != nil {
		return fmt.Errorf("parse id: %w", err)
	}

	fmt.Printf("Mark task %d as done\n", id) // Mark task 10 as done
	return nil
}

Yes, this is “manual work.” But it’s very honest: the input can be anything, but only what passes validation reaches the business logic. And most importantly — we didn’t lose numeric precision, because we didn’t convert values into float64 without asking.

2. json.RawMessage and two-step parsing

json.RawMessage: read the envelope and defer parsing the content

Sometimes the problem isn’t that JSON is dynamic “in general,” but that it has two layers. On the outside there’s a clear “envelope” (for example, an op or type field), and inside there’s data, whose structure depends on the value of that field. And that’s where decoding into map[string]any starts to feel like life without types, and life without types is like tea without sugar: you can do it, but why suffer?

json.RawMessage solves this neatly: it stores a chunk of JSON as []byte without parsing it immediately. So we first parse the “header” (the envelope), and then, knowing op, parse data into the required struct.

Let’s describe an envelope for tasker commands:

package main

import "encoding/json"

type CommandEnvelope struct {
	Op   string          `json:"op"`
	Data json.RawMessage `json:"data"`
}

Now an input example:

{"op":"done","data":{"id":10}}

And two-step parsing:

package main

import (
	"encoding/json"
	"fmt"
)

type DonePayload struct {
	ID int `json:"id"`
}

func decodeDone(env CommandEnvelope) (DonePayload, error) {
	var p DonePayload
	if err := json.Unmarshal(env.Data, &p); err != nil {
		return DonePayload{}, fmt.Errorf("decode done payload: %w", err)
	}
	return p, nil
}

The key idea here: we’re back in the typed world (struct), which means we get normal errors when types don’t match, and we don’t have to manually check any and do type assertions at every step.

To make it stick visually, here’s a data-flow diagram:

flowchart TD
    A[JSON input] --> B[Decode envelope into CommandEnvelope]
    B --> C{env.Op}
    C -->|done| D[Unmarshal env.Data into DonePayload]
    C -->|create| E[Unmarshal env.Data into CreatePayload]
    D --> F[tasker business logic]
    E --> F

The “command dispatcher” pattern: RawMessage + switch

When you have more than one command, you don’t want to smear if all over the code; you want one place that “routes” handling. This is especially nice in CLI/server code: one entry point, one dispatcher, clear errors.

Let’s make two commands: create and done. create will have a title field; done will have an id field.

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

type CreatePayload struct {
	Title string `json:"title"`
}

func handleCommand(r io.Reader) error {
	dec := json.NewDecoder(r)

	var env CommandEnvelope
	if err := dec.Decode(&env); err != nil {
		return fmt.Errorf("decode envelope: %w", err)
	}

	switch env.Op {
	case "create":
		var p CreatePayload
		if err := json.Unmarshal(env.Data, &p); err != nil {
			return fmt.Errorf("decode create: %w", err)
		}
		fmt.Printf("Create task: %s\n", p.Title) // Create task: Buy milk
		return nil

	case "done":
		var p DonePayload
		if err := json.Unmarshal(env.Data, &p); err != nil {
			return fmt.Errorf("decode done: %w", err)
		}
		fmt.Printf("Done task: %d\n", p.ID) // Done task: 10
		return nil

	default:
		return fmt.Errorf("unknown op %q", env.Op)
	}
}

Notice an important effect: DonePayload.ID is an int, so if {"id": 1.5} arrives, json.Unmarshal returns an error. In other words, you get strictness “almost for free” because you use typing.

Where does json.Number fit if we already use structs?

After the previous section it’s easy to think: “Okay, RawMessage is convenient, so why do we need json.Number at all?” That’s a good question: it means you’re not just copying code — you really understand what’s going on.

json.Number is needed in two typical situations.

The first situation: when the payload inside data is still decoded “dynamically” (for example, into map[string]any), because part of the fields may be arbitrary. Then you can enable UseNumber() on the decoder, decode data separately, and parse numbers manually.

The second situation: when you want to accept a number “in a general form,” and decide how to convert it later. For example, you’re writing an importer that accepts id as a number but allows it to be too large for int, and you want to keep it as a string and handle it later. In that case, json.Number as a field type in a struct can be useful specifically as a “deferred decision.”

For example, let’s make a payload where id is exactly json.Number:

package main

import (
	"encoding/json"
	"fmt"
)

type DonePayloadFlex struct {
	ID json.Number `json:"id"`
}

func (p DonePayloadFlex) ParseID() (int64, error) {
	id, err := p.ID.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	return id, nil
}

This looks a bit more “advanced,” but the idea is the same: the number does not get converted into float64; it remains a number that you then convert with validation.

Errors: add context and don’t lose the root cause

When you start doing two-step parsing (envelope → payload), errors become more informative… if you help them. Meaning: if you return a “bare err,” then somewhere up the stack you’ll see something like “unexpected end of JSON input” and you’ll have to guess: was it the envelope? was it data? which command did it come from?

The Go style is to add context and preserve the root cause via wrapping (%w). This is standard error-handling practice: we propagate the error up the stack, adding “what we were doing,” while keeping the original cause so it can still be recognized.

It’s also useful to remember that some standard-library errors have concrete types and extra fields. For example, encoding/json can return a SyntaxError with a position (Offset), and you can extract those details and turn them into friendly diagnostics. We won’t dive into JSON error classification right now, but the principle “the error type matters more than the error string” is worth remembering.

A mini example of “good wrapping” in our style:

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

func decodeEnvelope(r io.Reader) (CommandEnvelope, error) {
	dec := json.NewDecoder(r)

	var env CommandEnvelope
	if err := dec.Decode(&env); err != nil {
		return CommandEnvelope{}, fmt.Errorf("decode command envelope: %w", err)
	}
	return env, nil
}

That error text will then look like a chain: “decode command envelope: …”, and you immediately know which step failed. This isn’t a luxury — it’s how you avoid turning debugging into an archaeological dig.

3. Typical mistakes when working with json.Number and json.RawMessage

Mistake #1: expecting UseNumber() to “fix” decoding into a struct.
UseNumber() primarily affects decoding into any/map[string]any. If you decode into a struct with int/int64/float64 fields, the library already knows the target type and will parse the number directly into it. That’s why UseNumber() is useful where the type is not known in advance and you are forced to use any.

Mistake #2: extracting a json.Number but not checking the error from Int64()/Float64().
It’s very tempting to write id, _ := n.Int64() and “move on.” In practice that means: “if 1.5 arrived, we silently turn it into zero and keep going.” That bug later shows up as “why do we have a task with id=0” and “why does this happen sometimes.” You must always check the error: that is exactly the data-validation boundary.

Mistake #3: using map[string]any where RawMessage + structs are a perfect fit.
If you have a selector field (op, kind, type) and it defines the structure of the nested data block, then json.RawMessage is usually simpler and safer than a huge set of type assertions over any. You first read a small envelope, then decode the payload into the correct struct — and you get proper type errors.

Mistake #4: treating json.RawMessage as “just bytes” and forgetting it must be a valid JSON fragment.
RawMessage stores a chunk of JSON that will later be decoded by json.Unmarshal. If you manually modify these bytes somewhere, concatenate strings, add an extra comma, or cut off a piece — the next step will fail with a syntax error. RawMessage works best when you keep it as “what came in is what you pass to Unmarshal.”

Mistake #5: mixing responsibilities: “parse” and “execute.”
Beginners often start, inside a switch on env.Op, not only decoding the payload, but also immediately mutating application state, printing results, touching the file system, and the parsing/validation/execution logic becomes intertwined. It’s much simpler (and easier to test) to keep the idea: first decode + validate, and only then — the action. This reduces the number of places where “bad data” can slip into the business logic.

1
Task
Go SELF, level 46, lesson 2
Locked
Precision sensor
Precision sensor
1
Task
Go SELF, level 46, lesson 2
Locked
The Missing One
The Missing One
1
Task
Go SELF, level 46, lesson 2
Locked
Command envelope
Command envelope
1
Task
Go SELF, level 46, lesson 2
Locked
Sum without rounding
Sum without rounding
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION