Lazy attribute evaluation for JSONHandler

May 26, 2026

As I was writing yesterday’s post, a portion of the GoDoc confused me. I’ve now spent over 3 hours with Claude trying to parse the prose grammatically, build test cases, and make general sense of it. I think I finally have… Here’s hoping!

So, yesterday we saw how you can lazy-evaluate some values when using TextHandler. But the proposed solution (pass a fmt.Stringer rather than a literal string) has other, likely uninintended, consequences if you’re using JSONHandler:

Performance considerations

… Avoiding the call to String also preserves the structure of the underlying value. For example JSONHandler emits the components of the parsed URL as a JSON object.

Oops! This means that by passing &r.URL, we get lazy string evaluation for TextHandler, but for JSONHandler, we get:

"url":{"Scheme":"http","Opaque":"","User":null,"Host":"example.com","Path":"","Fragment":"","RawQuery":"","RawPath":"","RawFragment":"","ForceQuery":false,"OmitHost":false}

Meh… that’s probably NOT what you want. So how can you get that lazy-evaluation, but in string format, for JSONHandler?

… If you want to avoid eagerly paying the cost of the String call without causing the handler to potentially inspect the structure of the value, wrap the value in a fmt.Stringer implementation that hides its Marshal methods.

There’s the clue, but it’s opaque—and I believe actually wrong. Let me first explain what I think it’s trying to point us to. To get the lazy-evaluation benefit with JSONHandler, we need to rely on MarshalJSON or MarshalText instead of String. Easily done with a custom type that wraps the url.URL:

type myURL url.URL

func (u *myURL) MarshalJSON() ([]byte, error) {
  return json.Marshal((*url.URL)(u).String())
}

Or you could add a MarshalText method, which would work for both TextHandler and JSONHandler equally.

So there’s the full arc, as I believe it’s meant to be understood. You can stop reading now if you want to.

However, that’s not actually what the doc says.

The doc says “… wrap the value in a fmt.Stringer implementation that hides its Marshal methods.” — The solution we just came up with adds a Marshal method, it doesn’t hide any. And in fact, the url.URL example in the doc doesn’t have marshal methods (if it did, we wouldn’t need this remedy!)

So, I believe the doc simply has a small error, and means to say:

“… wrap the value with an implementation that adds Marshal methods.”

And that’s where I got hung up while trying to make sense of this. Maybe the doc is actually trying to say something else? Maybe it’s talking about data hiding? Maybe you want a custom fmt.Stringer that omits api_key query parameters for example? But that seems to be a stretch for what appears to be an aside in a section about performance—and doubly so, since the prescription points to fmt.Stringer, which is what introduced the problem it’s meant to be solving for JSONHandler. Which is why I think it’s just worded in a confusing/incorrect way.

How do you read it? Is there an interpretation I’ve overlooked?


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Related Content


Expanding errors with ReplaceAttr

Let’s look at one other example where I reach for ReplaceAttr regularly: Logging additional error detail. (I talk more about this approach in my Boot.Dev course Learn Logging and Observability in Go (see below for a discount code).) For this technique to be meaningful, I first need errors that contain additional details. And this can take many forms, but probably the most ubiquitous form is an error that contains a stack trace, such as those produced by the popular package github.


Test logging with ReplaceAttr

Today’s topic is probably the most interesting, subtle, and complicated of the three HandlerOptions fields: ReplaceAttr. type HandlerOptions type HandlerOptions struct { … // ReplaceAttr is called to rewrite each non-group attribute before it is logged. // The attribute's value has been resolved (see [Value.Resolve]). // If ReplaceAttr returns a zero Attr, the attribute is discarded. // // The built-in attributes with keys "time", "level", "source", and "msg" // are passed to this function, except that time is omitted // if zero, and source is omitted if AddSource is false.


Handler level

Each handler has an optional Level value, which controls which logs to drop, and which to actually log: type HandlerOptions type HandlerOptions struct { … // Level reports the minimum record level that will be logged. // The handler discards records with lower levels. // If Level is nil, the handler assumes LevelInfo. // The handler calls Level.Level for each record processed; // to adjust the minimum level dynamically, use a LevelVar.

Get daily content like this in your inbox!

Subscribe