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


slog.HandlerOptions

Now we’ll begin looking at HandlerOptions. Despite the Handler-centricity, this is something we care heavily about as mere users of the slog library. Whenever you instantiate either slog.JSONHandler or slog.TextHandler, you have the option of providing a HandlerOptions object, to tweak the default handler behavior. We’ll look at each of the config options, one at a time. type HandlerOptions type HandlerOptions struct { // AddSource causes the handler to compute the source code position // of the log statement and add a SourceKey attribute to the output.


The better test alternative to slog.DiscardHandler

Last time I described slog.DiscardHandler as a viable way to silence logs in tests. Now I want to tell you why I never use that option, and you probably shouldn’t, either. But first a story. The slog package was introduced with Go 1.21 in August of 2023. It didn’t yet have slog.DiscardHandler, so you had to roll your own. But that was easy: logger := slog.New(slog.NewTextHandler(io.Discard, nil)) Easy as it was, it was also annoying.


slog.DiscardHandler

Now that we’ve made it through the Handler interface, let’s look at the first of the implementations provided by the standard library: the DiscardHandler. var DiscardHandler Handler = discardHandler{} DiscardHandler discards all log output. DiscardHandler.Enabled returns false for all Levels. Why would you ever want a discard handler? Two likely scenarios come to mind. The one where I most frequently use it, is testing. You may not wish to log anything during a test, and this is a simple way to do that:

Get daily content like this in your inbox!

Subscribe