Skip to content

Beautiful, Structured Logs for Go CLIs with charmbracelet/log

VHS

If most of the logs from your Go CLI still look like a wall of monochrome timestamps and free-form strings, you're leaving a lot of developer experience on the table. Charm's log package gives you colorful, readable, structured terminal output without forcing you into a heavyweight API or a machine-first JSON mindset from the start.

There's nothing wrong with simple logging. The problem is that simple logging tends to stay simple long after your application stops being simple.

At first, a few log.Println calls feel harmless. Then the output gets noisy. Errors look almost the same as info messages. Request context gets squeezed into ad hoc strings. The one line you need is somewhere in the middle of a hundred identical timestamps, and your terminal gives you very little help separating signal from noise.

That's the gap charmbracelet/log tries to fill. It's a leveled, structured, human-readable logger built for terminals. It looks good out of the box, keeps the API small, and still gives you the structured fields and formatting controls you want for real projects.

Import Path Note

The project is hosted on GitHub at github.com/charmbracelet/log, but current v2 releases use the vanity import path charm.land/log/v2.

In other words, this post is about the Charm logger project you know from GitHub, but the modern code examples below use the current import path:

import log "charm.land/log/v2"

Standard Library log

The standard library's log package has one enduring strength: everyone already knows it.

You can drop it into a program in seconds:

package main

import (
    "fmt"
    stdlog "log"
)

func main() {
    addr := ":8080"
    stdlog.Printf("starting server on %s", addr)
    stdlog.Printf("request failed: %v", errDatabaseUnavailable())
}

func errDatabaseUnavailable() error {
    return fmt.Errorf("database unavailable")
}

The output is functional, but not especially helpful:

2026/04/17 09:14:03 starting server on :8080
2026/04/17 09:14:03 request failed: database unavailable

There are a few obvious limitations:

  • There aren't any built-in levels like DEBUG, INFO, WARN, and ERROR.
  • Everything is plain text, so the output is only as structured as your string formatting habits.
  • It's monochromatic and visually flat in the terminal.
  • Caller information and richer formatting require extra work.

To be fair, Go 1.21 improved the standard library story substantially with log/slog. slog gives you levels, structured attributes, and standard text or JSON handlers. That's a very good baseline. But even slog's default text output is deliberately utilitarian. It's designed to be predictable, not delightful.

If your primary audience is a human looking at a terminal, charmbracelet/log is often the nicer default.

Why Forego the Standard Library

Once a codebase grows up, developers may want one or more of these:

  • log levels they can filter
  • structured fields instead of string interpolation everywhere
  • JSON output for ingestion by log pipelines
  • consistent formatting across services and tools
  • better ergonomics while debugging locally

That's why packages like logrus, zap, and zerolog became so popular.

Alternative: logrus, zap, and zerolog

These libraries earned their place for good reasons.

sirupsen/logrus

sirupsen/logrus helped normalize leveled and structured logging in Go long before slog existed. Its API is approachable, it integrates with a lot of existing code, and many teams still have it in older services.

The tradeoff is that logrus is now in maintenance mode. It still works, but it's no longer the forward-looking choice for most new projects.

uber-go/zap

uber-go/zap is excellent when performance matters and you want low-allocation structured logging. Its typed fields and production JSON output make a lot of sense for busy services.

The tradeoff is that its API can feel more explicit and ceremony-heavy than what many developers want for a CLI, a local tool, or everyday terminal-oriented development.

rs/zerolog

rs/zerolog is also extremely fast and is built around efficient, structured output with a fluent API.

The tradeoff is similar: it shines when machine-readable logs and throughput are the primary goal, but its terminal experience isn't really the main attraction.

None of that is a criticism. It's just a reminder that these tools optimize for a different center of gravity.

If your logs are headed straight for Elasticsearch, Loki, Cloud Logging, or a similar backend, zap and zerolog are serious options. If your logs are headed to your own eyeballs in a terminal window, the priorities shift.

Enter charmbracelet/log

This is where Charm's logger gets interesting.

charmbracelet/log isn't trying to beat zap or zerolog in benchmark shootouts. Its value proposition is better developer experience:

  • colorful, readable output out of the box
  • clear level formatting
  • structured key-value fields without a lot of ceremony
  • optional caller reporting
  • styling customization via Lip Gloss
  • formatter support for text, JSON, and logfmt
  • compatibility with log/slog on Go 1.21+

That combination makes it especially compelling for:

  • command-line tools
  • local development logs
  • internal tooling
  • scripts and automation with human-facing output
  • services where developers spend a lot of time tailing logs directly

The big win is that it feels good immediately. You add it, run your program, and your logs become easier to scan before you've done any elaborate configuration at all.

Comparing the Experience

Let's compare the experience directly.

Before: Standard Library log

package main

import (
    stdlog "log"
)

func main() {
    userID := 42
    orderID := "ord_123"

    stdlog.Printf("processing order %s for user %d", orderID, userID)
    stdlog.Printf("payment declined for order %s", orderID)
}
2026/04/17 09:32:51 processing order ord_123 for user 42
2026/04/17 09:32:51 payment declined for order ord_123

That output isn't wrong. It's just flat. The important parts are buried inside the sentence, and you have to infer severity from the wording.

After: charmbracelet/log

package main

import (
    "os"

    log "charm.land/log/v2"
)

func main() {
    logger := log.NewWithOptions(os.Stderr, log.Options{
        Level:           log.DebugLevel,
        ReportTimestamp: true,
    })

    userID := 42
    orderID := "ord_123"

    logger.Info("processing order", "order_id", orderID, "user_id", userID)
    logger.Warn("payment declined", "order_id", orderID, "retryable", false)
}

Now the output has visual hierarchy. Levels stand out, fields are consistently shaped, and the whole line is easier to parse at a glance.

Here's the same progression as a terminal recording: standard library log, default charmbracelet/log, and then a styled charmbracelet/log variant using Lip Gloss-backed custom styles.

Standard library log, default charmbracelet/log, and styled charmbracelet/log terminal output

This GIF was generated with VHS from a reproducible tape checked into the repository, which makes the comparison easy to rerender and refine as the post evolves.

Want to Build Terminal Demos Like This?

If you want the full walkthrough on authoring .tape files, rendering GIFs, and treating terminal recordings as source code, see How to Create Terminal Demos as Code with VHS by Charm.

Configuring Levels Cleanly

One of the first things you usually want is predictable level handling:

package main

import (
    "os"

    log "charm.land/log/v2"
)

func main() {
    logger := log.NewWithOptions(os.Stderr, log.Options{
        Level:           log.DebugLevel,
        ReportTimestamp: true,
    })

    logger.Debug("loading config", "path", "./config.yaml")
    logger.Info("server started", "addr", ":8080")
    logger.Warn("cache miss spike", "region", "us-east-1")
    logger.Error("request failed", "status", 503, "retry_in", "5s")
    // logger.Fatal("unrecoverable startup failure", "err", err)
}

This is a small thing, but it matters: the API is simple enough that you don't have to stop and think about it.

Structured Logging Without Friction

Structured logging is where the standard library log package starts to feel dated immediately. With Charm's logger, key-value fields are built into the happy path:

package main

import (
    "os"
    "time"

    log "charm.land/log/v2"
)

func main() {
    logger := log.NewWithOptions(os.Stderr, log.Options{
        Level: log.InfoLevel,
    })

    started := time.Now()

    logger.Info(
        "request complete",
        "method", "GET",
        "path", "/api/orders",
        "status", 200,
        "duration", time.Since(started),
        "request_id", "req_01jswq1jkn9hjx8v1d2f9k3d7m",
    )
}

You can also create sub-loggers with shared context:

package main

import (
    "os"

    log "charm.land/log/v2"
)

func main() {
    logger := log.New(os.Stderr).With("component", "worker", "queue", "emails")
    logger.Info("job claimed", "job_id", "job_987")
    logger.Error("job failed", "job_id", "job_987", "attempt", 3)
}

That gives you a nice middle ground between bare strings and heavily optimized structured logging APIs.

Caller Reporting with Almost No Effort

When you're debugging, file and line information can be worth its weight in gold.

charmbracelet/log makes this easy:

package main

import (
    "os"

    log "charm.land/log/v2"
)

func main() {
    logger := log.NewWithOptions(os.Stderr, log.Options{
        ReportCaller:    true,
        CallerFormatter: log.ShortCallerFormatter,
    })

    logger.Error("failed to parse configuration", "path", "./config.yaml")
}

That produces caller output like cmd/app/main.go:14 without needing a separate logging stack or custom wrapper.

If you write helper functions around logging, the package also supports marking helper frames so caller reporting points to your code, not your wrapper. That's a thoughtful feature for real applications.

Styling Is a First-Class Feature

This is one of the clearest differences between Charm's logger and most alternatives.

With charmbracelet/log, terminal aesthetics aren't an accidental side effect. The text formatter is styled with Lip Gloss, and you can override default styles when you want to customize how levels, keys, and values appear.

package main

import (
    "os"

    lipgloss "charm.land/lipgloss/v2"
    log "charm.land/log/v2"
)

func main() {
    styles := log.DefaultStyles()
    styles.Levels[log.ErrorLevel] = lipgloss.NewStyle().
        SetString("ERROR").
        Bold(true).
        Foreground(lipgloss.Color("204"))

    logger := log.New(os.Stderr)
    logger.SetStyles(styles)
    logger.Error("payment provider unavailable", "provider", "stripe")
}

This isn't the kind of customization every service needs, but it's fantastic for CLIs and tools where the terminal output is part of the product experience.

It Plays Nicely with slog

One of my favorite things about this package is that it doesn't force an either-or decision between modern standard library logging and nicer terminal output.

On Go 1.21 and later, *log.Logger from Charm implements slog.Handler, so you can keep slog as your API surface and use Charm as the human-friendly handler:

package main

import (
    "log/slog"
    "os"

    charmlog "charm.land/log/v2"
)

func main() {
    handler := charmlog.NewWithOptions(os.Stderr, charmlog.Options{
        Level:           charmlog.InfoLevel,
        ReportTimestamp: true,
        ReportCaller:    true,
    })

    logger := slog.New(handler)
    logger.Info("startup complete", "version", "1.4.0", "port", 8080)
}

That's a strong story for teams already leaning into slog. You don't need to choose between standardized structured logging and logs that are pleasant to read in a terminal.

When charmbracelet/log Is the Right Tool

Use it when:

  • humans read the logs directly
  • terminal output quality matters
  • you want structured fields without a large API surface
  • you're building CLIs, dev tools, local services, or internal automation
  • you want a polished default without a lot of configuration

Reach for something else when:

  • raw throughput and allocation profiles are the top priority
  • JSON is the primary production format and humans rarely read the raw output
  • you're standardizing on an ecosystem already built around zap or zerolog

This is the real verdict: zap and zerolog are outstanding for high-throughput production logging, especially when the main consumer is a machine. charmbracelet/log shines when the consumer is a developer at a terminal.

That's not a small niche. It covers a lot of the Go programs we write every day.


If you care about developer experience, terminal readability, and structured logs that don't feel like paperwork, charmbracelet/log is easy to recommend.

It improves the day-to-day feel of logging in a way that the standard library log package never tried to, and it does so without becoming unwieldy. It's especially compelling for CLIs, developer tools, and local workflows where aesthetics and scannability genuinely matter.

If you want to try it, install the current release with:

go get charm.land/log/v2@latest

Then spend ten minutes replacing a few log.Printf calls in one of your tools. There's a good chance you won't want to go back.

If you've been using charmbracelet/log, or if you prefer zap, zerolog, slog, or something else entirely, where do you draw the line between terminal-first DX and production JSON in the same codebase?