1. Why “just print err” doesn’t help
When you start writing programs, you really want to do this: “if there’s an error, print err and that’s it.” It’s natural: at least the program isn’t silent. But pretty quickly it turns out that the bare error text is often either too technical (invalid character '}' looking for beginning of object key string), or too generic (unexpected EOF), or not helpful at all (cannot unmarshal number into Go struct field ...).
In Go, it’s important to remember the idea errors are values—errors aren’t magic spells; they’re ordinary values that you can analyze and improve. So a good strategy looks like this: inside the program we preserve the cause as precisely as possible (so code can recognize it), and at the program boundary (CLI, import, config) we turn it into a message a human can understand. It’s like translation: inside the team you speak “as developers,” but to the user you speak plain language.
2. Operation context and error wrapping
Imagine you get an error unexpected EOF. Where did it come from? From a file? From stdin? Were we reading tasks.json? Or config.json? Or is it a truncated chunk of data in the middle?
This is where operation context shows up: when an error bubbles up the stack, each layer adds “what it was doing.” In Go this is usually done via fmt.Errorf("...: %w", err). The key point is exactly "%w": this is how we wrap the error while keeping the original cause inside the chain, so it can be recognized via errors.Is/errors.As.
Mini example: read the tasks JSON and add context:
package main
import "fmt"
func loadTasksJSON() error {
err := fmt.Errorf("unexpected EOF") // imagine this came from json.Decoder
return fmt.Errorf("read tasks file: %w", err)
}
If you print err.Error(), a person will see read tasks file: unexpected EOF. That’s already better: it’s clear where the error happened.
But there’s an important nuance: by wrapping an error, you make it part of your contract. If you return an error from a foreign package to the outside (for example, sql.ErrNoRows or an internal DB error type), you’re effectively promising users of your code that this cause will always be there. Go blog posts emphasize this separately: wrapping is an API decision, not just “pretty text.”
A practical rule of thumb for our level: if the error is an “implementation detail” and you don’t want to “sell it externally,” you can add context via "%v" (not via "%w"). But at the JSON input boundary, the cause is most often useful, because it explains what exactly is wrong with the incoming data.
"%w" vs "%v": why this is a contract decision
A very common beginner mistake is to think "%w" is needed simply “to look nice.” In reality, "%w" is not about aesthetics; it’s about enabling higher-level code to say: “aha, inside there is a *json.SyntaxError” and to extract details via errors.As.
Go 1.13 standardized wrapping: "%w" makes an error unwrap-able, and errors.Is/errors.As start seeing the whole chain. This is convenient, but it requires discipline: if you wrap someone else’s error and return it outward, you are promising that this cause can be recognized.
Materials on errors emphasize that this can “leak” across abstraction boundaries: once an implementation detail becomes accessible, you can no longer easily replace it without breaking users.
For our JSON input scenario, wrapping is almost always justified: the error cause is part of the input contract. If the JSON is broken, that’s not an “implementation detail”; that’s the essence of the problem.
3. A user message and a diagnostic cause
In application code there are almost always two truths about an error. The first truth is a short human message: “the tasks file contains invalid JSON.” The second truth is the technical cause: “SyntaxError at offset 183.”
A classic Go technique is to have an error struct that separately stores what you show the user and what you keep as the cause. In older but very illustrative Go code examples, this is done via appError with fields Message (for the user) and Error (for logs/diagnostics).
We’ll do a similar idea, just simpler and without HTTP.
A minimal “user-facing” error with unwrap
package main
type InputError struct {
Msg string
Err error
}
func (e *InputError) Error() string { return e.Msg }
func (e *InputError) Unwrap() error { return e.Err }
Notice the style: Error() returns a short message that you’re not ashamed to show a human. And Unwrap() preserves the cause for errors.As/errors.Is.
A small JSON error “translator”
Now let’s assemble the core idea: create a function that takes an error (most likely already wrapped with context), tries to extract useful details, and returns an *InputError with a human Msg and the original cause inside.
Let’s start with a simple skeleton. It’s intentionally short so you can rewrite it yourself without the feeling of “I’m reading someone else’s thesis.”
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
)
func wrapJSONInput(err error) error {
if errors.Is(err, io.EOF) {
return &InputError{Msg: "expected JSON, but the input is empty", Err: err}
}
var se *json.SyntaxError
if errors.As(err, &se) {
msg := fmt.Sprintf("invalid JSON (syntax), position %d", se.Offset)
return &InputError{Msg: msg, Err: err}
}
return &InputError{Msg: "failed to read JSON", Err: err}
}
Pay attention to the style: messages are short, no period at the end, no “Error:”. And yes—your program now sounds like a normal assistant, not like a suffering printer.
4. Typed errors in encoding/json
Errors from encoding/json are not just strings. Some of them have concrete types, and that’s a real gift: you can extract structured details and make the message smarter.
Most useful in practice:
- *json.SyntaxError — JSON syntax is broken. The important field is Offset (position in bytes). The idea of using Offset for a more precise message (for example, line/col) appears even in official materials about error handling.
- *json.UnmarshalTypeError — the JSON is valid, but the type doesn’t match your struct (for example, "id": "abc" instead of a number). It often contains the field name and information about what was expected.
The recognition skeleton using errors.As looks like this:
package main
import (
"encoding/json"
"errors"
)
func isSyntax(err error) bool {
var se *json.SyntaxError
return errors.As(err, &se)
}
And here is how you can extract details:
package main
import (
"encoding/json"
"errors"
"fmt"
)
func syntaxOffset(err error) (int64, bool) {
var se *json.SyntaxError
if !errors.As(err, &se) {
return 0, false
}
return se.Offset, true
}
The important habit here: we don’t compare the text err.Error(), we look at the type. It’s more reliable and survives changes in error wording.
5. Offset, line, and column
One thing is to say “position 183,” another is “line 12, column 5.” Line/column is clearer to a human. But there’s a nuance: Offset is a position in bytes, and to compute line and column you need the original text in full.
If you’re reading a small config or a small JSON file, you can afford to read everything into memory (for example, via io.ReadAll) specifically for good diagnostics. This is a reasonable trade-off: user experience is often more important than micro-optimizations.
Mini function for line/col from a byte slice:
package main
func lineCol(data []byte, offset int64) (line, col int) {
line, col = 1, 1
for i := int64(0); i < offset-1 && i < int64(len(data)); i++ {
if data[i] == '\n' {
line, col = line+1, 1
} else {
col++
}
}
return line, col
}
Yes, this doesn’t properly account for runes and UTF-8, but for byte-based JSON diagnostics it’s usually enough: encoding/json JSON errors also count offsets in bytes.
6. Integrating into an application: strict TaskFile decode
Now let’s tie everything to our tutorial application—a simple task manager. Let’s say we have a “task file” format where we store a version and a list of tasks.
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type TaskFile struct {
Version int `json:"version"`
Tasks []Task `json:"tasks"`
}
We strictly read one JSON document, and in case of an error we add operation context, and then at the boundary we turn it into an *InputError.
First: strict decode + context:
package main
import (
"encoding/json"
"fmt"
"io"
)
func decodeTaskFile(r io.Reader) (TaskFile, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var tf TaskFile
if err := dec.Decode(&tf); err != nil {
return TaskFile{}, fmt.Errorf("decode task file: %w", err)
}
return tf, nil
}
Now: the “application boundary”—a conditional main, where we decide what to show the user.
package main
import (
"fmt"
"os"
)
func main() {
_, err := decodeTaskFile(os.Stdin)
if err != nil {
err = wrapJSONInput(err)
fmt.Fprintln(os.Stderr, "Error:", err) // Error: failed to read JSON
os.Exit(1)
}
fmt.Println("OK") // OK
}
The point is that decodeTaskFile doesn’t try to be “friendly.” Its job is to correctly read data and preserve the cause. Friendliness is the job of the “boundary” (CLI). This approach is very close to the idea “show the user a simple message, but keep details for diagnostics.”
7. Common mistakes when presenting JSON errors
Mistake #1: returning a “naked” error without context.
If you just do return err, then at the top level you see unexpected EOF and start playing detective: EOF where, why, at what step? It’s much better to add context at each layer: "decode task file", "read config", "unmarshal versioned envelope". This turns the error string into a story of actions rather than a riddle.
Mistake #2: using "%v" instead of "%w" where you want to recognize the cause.
The phrase "decode task file: …" will look the same with "%v" and with "%w", and that’s tricky. But only "%w" will preserve the cause so that higher up you can do errors.As(err, &se) and get the Offset from *json.SyntaxError. Wrapping is not cosmetics; it’s a mechanism.
Mistake #3: classifying errors by err.Error() and substrings when a type exists.
Error strings can change, be localized, be expanded. If you can recognize *json.SyntaxError or *json.UnmarshalTypeError via errors.As, do it—it’s much more reliable. And the very idea “an error is a value; you can analyze it” is considered fundamental in Go.
Mistake #4: showing the user overly technical details.
The message cannot unmarshal number into Go struct field Task.id of type string is very informative for a developer, but for a user it’s noise and a perfectly valid reason to start hating your program. It’s better to show “field id must be a number,” and keep the technical cause inside Unwrap() so you can retrieve it during debugging.
Mistake #5: not handling the empty-input case (io.EOF).
io.EOF often means “the input is empty,” not “something broke.” If your function contract expects JSON, empty input is a clear user/file error. If you don’t handle this case, the user will get a strange EOF and won’t understand what happened.
GO TO FULL VERSION