Swift Logging - Use OSLog Instead of print()

October 17, 2026

print() is the first debugging tool most Swift developers reach for, and it works fine until an app ships. Then it quietly keeps running. Every print() call you forgot to remove still formats its string, still writes to standard output, and still blocks the thread doing it, all in a build where nobody is reading the output.

The unified logging system (OSLog) solves this. Messages you log with Logger are cheap when nobody is listening, structured when someone is, and viewable in Console with filters, categories, and severity colors that print() can never provide.

This article covers:

  • Why print() has a real cost in release builds
  • How to declare a Logger with a subsystem and category
  • How to choose log levels and handle privacy
  • How to filter, color-code, and stream your logs in Console

Why print() hurts release builds

print() is not a logging API. It's a convenience for writing text to standard output, and it carries three costs that don't go away in Release configuration:

It's never compiled out. The optimizer doesn't know that your print() calls are debugging aids. Unless you wrap each one in #if DEBUG, every call survives into the App Store binary.

It evaluates eagerly. String interpolation runs before print() is called. If you write print("User: \(user.debugDescription)"), you pay for the full description (allocation, formatting, and any computed properties it touches) whether or not anything reads the result.

It's synchronous and unformatted. Output goes to stdout, serialized through a lock. In a GUI app there's often no terminal attached, so the text goes nowhere useful, but the work still happens on whatever thread called it. And when it does land somewhere, it arrives as a flat line of text: no timestamp, no severity, no indication of which part of your app produced it.

Logger reverses each of these. The message is stored as a compact format string plus arguments. Formatting is deferred until a tool actually reads the log, and for debug-level messages with no observer attached, it never happens at all.


Declaring a logger

Import the os framework and create a Logger with a subsystem and a category. The subsystem is typically your bundle identifier; the category describes a functional area of your app.

import os

extension Logger {
    /// The subsystem used by every logger in this app.
    private static let subsystem = Bundle.main.bundleIdentifier!

    /// Logs related to network requests and responses.
    static let networking = Logger(subsystem: subsystem, category: "Networking")

    /// Logs related to persistence and Core Data.
    static let persistence = Logger(subsystem: subsystem, category: "Persistence")

    /// Logs related to view lifecycle and user interaction.
    static let ui = Logger(subsystem: subsystem, category: "UI")
}

Declaring loggers as static properties in one extension gives you a single place to see every category in the app and keeps call sites short:

Logger.networking.info("Fetching feed for \(userID)")

Note > Logger is available on iOS 14, macOS 11, and later. Instances are lightweight and safe to share across threads, so a static property is the recommended pattern.


Choosing a log level

Logger exposes five levels. Each one tells the system how much effort to spend storing the message, which directly affects both runtime cost and what you'll find in Console later.

Level Method Persisted to disk? Use for
Debug debug(_:) No Verbose detail useful only while actively debugging
Info info(_:) Only when an error or fault occurs nearby Helpful context that explains what led to a problem
Notice notice(_:) (also log(_:)) Yes, until storage limits are reached Ordinary events worth keeping
Error error(_:) Yes Recoverable failures
Fault fault(_:) Yes Bugs and unrecoverable states
func loadFeed() async {
    Logger.networking.debug("loadFeed() entered")

    do {
        let items = try await client.fetchFeed()
        Logger.networking.info("Received \(items.count) feed items")
    } catch {
        Logger.networking.error("Feed request failed: \(error.localizedDescription)")
    }
}

The level decides what shows up in Console. Debug messages only appear when you're streaming live; info messages are kept in memory and flushed to disk only if something goes wrong, so they're ideal for the breadcrumbs you want around an error without the cost of writing every one.


Protecting user data with privacy annotations

By default, interpolated values that could contain user data (strings, objects, collections) are redacted in the stored log and appear as <private> in Console. Numbers and other scalars are shown. This is a feature: it means you can log freely without leaking personal information into diagnostic files that users might share.

When a value is safe to display, mark it explicitly:

Logger.persistence.notice("Opened store at \(storeURL.lastPathComponent, privacy: .public)")

For values that should stay hidden but still need to be correlated across log lines, use a hashed representation:

Logger.ui.info("Selected account \(accountID, privacy: .private(mask: .hash))")

The same identifier produces the same hash each time, so you can trace a user's path through the log without ever seeing the raw ID.

Important Don't work around redaction by converting values to strings before logging them. "\(String(describing: user))" is still a string interpolation and is still redacted. Use privacy: .public deliberately, and only for data you'd be comfortable seeing in a bug report.


Formatting values

Interpolations accept formatting options so you don't have to pre-format values with String(format:):

Logger.networking.info("Response time: \(elapsed, format: .fixed(precision: 2)) s")
Logger.persistence.debug("Cache hit rate: \(hitRate, format: .fixed(precision: 1))%")
Logger.ui.debug("Frame \(frameIndex, align: .right(columns: 5))")

Because formatting happens when the log is read, not when it's written, these options cost nothing at the call site.


Reading logs in Console

Console (/Applications/Utilities/Console.app) reads the unified log for any connected device, including the Mac itself, iOS devices over USB, and Simulator instances.

Step 1: Enable Info and Debug messages

By default Console hides the two lowest levels. From the Action menu, choose Include Info Messages and Include Debug Messages. Debug messages are only delivered while Console is streaming, so turn this on before you reproduce the behavior you're investigating.

Step 2: Filter to your subsystem

Type your bundle identifier into the search field. Console offers a menu on the search token; choose Subsystem so the filter matches the subsystem you passed to Logger rather than any message text that happens to contain it.

Add a second token for Category to narrow further, for example Networking, or save the filter from the search field's menu so it's one click away next time.

Step 3: Read the colors

Console color-codes each line by level, so severity is visible at a glance:

  • No marker: Notice and Info, routine messages
  • Gray dot: Debug, verbose detail only shown while streaming
  • Yellow dot: Error, something failed but the app recovered
  • Red dot: Fault, a bug or invariant violation that needs attention

The Type, Subsystem, and Category columns can be shown or hidden from the View menu. With a subsystem filter and these columns visible, the log reads as a timeline of your app's components rather than an undifferentiated stream of text.

Step 4: Start streaming

Click Start in the toolbar, then exercise the feature you're debugging. Messages appear in real time, already sorted, filtered, and colored. Select any line to see its full metadata in the detail pane: process, thread, timestamp with sub-millisecond precision, and the source library.


Streaming from the command line

For scripted workflows, or when Console's UI is more than you need, the log tool exposes the same data in Terminal:

log stream --level debug \
  --predicate 'subsystem == "com.example.MyApp"' \
  --style compact

To retrieve logs that were already written, for instance after a crash on a device, collect an archive and open it in Console:

sudo log collect --last 1h --output ~/Desktop/MyApp.logarchive

Migrating existing print() calls

You don't need to migrate everything at once. A practical order:

  1. Replace error paths first. Every print("Error: ...") becomes Logger.<category>.error(...). These are the messages you most want persisted.
  2. Convert startup and lifecycle messages to notice. They're cheap and give you a reliable timeline.
  3. Demote diagnostic chatter to debug. Anything you'd normally delete before shipping can stay, because it costs almost nothing when nobody's watching.
  4. Delete what's left. If a message doesn't fit any level, it probably wasn't telling you anything.

Once the migration is done, you can treat a bare print() in the codebase as a lint failure. There's no situation where it's the better choice.


Summary

  • print() always runs, always formats, and always writes, even in release builds where the output goes nowhere.
  • Logger defers formatting until a tool reads the message, and skips the work entirely for debug messages with no observer.
  • Subsystems and categories turn your logs into a filterable, navigable structure in Console, with severity visible as color.
  • Privacy annotations are on by default, so logs stay safe to share.

Start with one Logger extension and one category. The first time you filter Console to a single subsystem and watch your app's components narrate themselves in order, you won't go back.

Thank you for reading. If you have any questions feel free to follow me on X and send me a DM. If this article helped you, Buy me a coffee.