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:
print() has a real cost in release buildsLogger with a subsystem and categoryprint() hurts release buildsprint() 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.
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 >
Loggeris 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.
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.
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. Useprivacy: .publicdeliberately, and only for data you'd be comfortable seeing in a bug report.
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.
Console (/Applications/Utilities/Console.app) reads the unified log for any connected device, including the Mac itself, iOS devices over USB, and Simulator instances.
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.
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.
Console color-codes each line by level, so severity is visible at a glance:
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.
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.
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 compactTo 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.logarchiveprint() callsYou don't need to migrate everything at once. A practical order:
print("Error: ...") becomes Logger.<category>.error(...). These are the messages you most want persisted.notice. They're cheap and give you a reliable timeline.debug. Anything you'd normally delete before shipping can stay, because it costs almost nothing when nobody's watching.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.
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.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.