Why if err != nil Feels Tedious
In one line
Go returns errors as values. So you cannot skip error handling, and the code gets longer in exchange. That length is not a cost; it is the price of explicitness.
Why this was needed
In a language with exceptions, you cannot tell from the code alone which line might throw. To check "can this die here?" you have to follow the call graph.
Go writes that down in the signature.
func ReadConfig(path string) (*Config, error)
The type says that this function can fail. The caller cannot ignore it: to ignore it, you have to write _, and that makes your intent visible.
Wrapping
If you pass an error up unchanged, you lose "where it came from." Wrap it with %w.
if err != nil {
return fmt.Errorf("설정 읽기(%s): %w", path, err)
}
%w holds the original inside. That lets the layers above ask about the cause with errors.Is / errors.As.
if errors.Is(err, os.ErrNotExist) { ... }
var perr *os.PathError
if errors.As(err, &perr) { fmt.Println(perr.Path) }
If you wrap with %v, only a string is left and this question becomes impossible. Always use %w when wrapping.
When to panic
Almost never. A panic is only for when "there is no reason for the program to keep running": a failed initialization or a violated invariant. A panic during request handling should kill only that request, so HTTP servers recover from it by default.
Common misconceptions
"Log the error and also return it." Then the same incident is printed in the log several times. Do only one of handling it or passing it up. The log entry is written once, at the place that finally handles it.
"If the error is nil, the result is valid." This is true by Go convention, but it is not enforced. Depending on the library, both can be valid, or both can be nil. Check the documentation.
Sentinel errors or custom types?
Decide by what the caller needs to know.
If the caller only needs to know "is it this case?", use a sentinel. Declare one value at package level and compare it with
errors.Is.
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) { ... }
If the caller needs extra information, use a type. Things like which field was wrong and why must be carried in the value.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}
var verr *ValidationError
if errors.As(err, &verr) {
log.Printf("필드 %s 가 문제: %s", verr.Field, verr.Reason)
}
There is one trap to watch for. If you put a nil custom error into the error interface, err != nil becomes true.
func do() error {
var e *ValidationError // nil
...
return e // ❌ 인터페이스는 (타입, nil) 이라 nil 이 아니다
}
if do() != nil { /* 오류가 없는데 여기로 들어온다 */ }
Do not return a concrete-type variable directly; when there is no error, write return nil explicitly.
This is the oldest trap in Go, and it still shows up today.
Several errors at once
Since Go 1.20, errors.Join bundles several errors together. It fits places like validation, where you have to "collect everything and report it at once."
var errs error
for _, f := range fields {
if err := f.Validate(); err != nil {
errs = errors.Join(errs, err)
}
}
return errs // 하나도 없으면 nil 이다
fmt.Errorf also accepts several %w verbs. errors.Is walks through all of the bundled errors.
What each layer adds
When you wrap an error, add only what you yourself know. If you repeat what the layer below has already said, the message just gets longer.
| Layer | Add | Do not add |
|---|---|---|
| Repository | Table and key | The full SQL (keep it in the log only) |
| Service | What it was trying to do | Repository details |
| Handler | Request identifier | Internal structure |
Also separate the message you send to the user from the message you write to the log. If an internal path goes out in the HTTP response as is, that is an information leak.
if err != nil {
log.Printf("주문 조회 실패: %v", err) // 전체 사슬
http.Error(w, "일시적인 오류입니다", 500) // 사용자에게는 이것만
}
What really matters in practice
Write error messages so that read from top to bottom, they form a path.
사용자 조회(id=42): 설정 읽기(/etc/app.yaml): open /etc/app.yaml: no such file
When each layer attaches only the context it knows, one piece at a time, you get this. By convention, a message does not start with a capital letter and does not end with a period, because it gets embedded inside other messages.