Logging in Swift with os.Logger
Most iOS codebases I've inherited log with print(). It works right up until it doesn't: print() output vanishes the moment you unplug from Xcode, it has no severity levels, no way to filter, no structure, and it happily dumps user emails and auth tokens into a stream that anyone with a Mac and a cable can read.
Apple ships a better answer in the standard library, and it's a single import away. os.Logger is fast, structured, privacy-aware, and readable from Console.app, Instruments, and the log command line tool. It has been the right default since iOS 14 and most people still aren't using it.
This post covers the fundamentals: how to create loggers, how the levels differ, what actually gets persisted to disk, and how to read your logs back. If you already know all that and want to forward logs to Datadog or Kafka, I wrote a follow-up on provider-agnostic logging that builds a routing layer on top of everything here.
Your First Logger
The whole API starts with one import and one initializer:
import os
let logger = Logger(subsystem: "com.kylebrowning.landmarks", category: "Networking")
logger.info("Fetching landmarks")
The two arguments do different jobs.
Subsystem identifies your app or module. Convention is reverse-DNS, matching your bundle identifier. Every logger in a single app usually shares one subsystem.
Category identifies the area within that subsystem. Networking, Persistence, Auth, LandmarkStore. This is what you'll filter on when you're staring at thousands of lines trying to find one request.
Together they form a two-level namespace, which is what makes filtering possible later. The common mistake is giving each file its own subsystem. Do that and you can still filter one file at a time, but you can no longer pull up the whole app in one query.
Where to Put Loggers
Make the logger a stored property on the type that uses it:
struct LandmarkService {
private let logger = Logger(
subsystem: "com.kylebrowning.landmarks",
category: "LandmarkService"
)
func fetchAll() async throws -> [Landmark] {
logger.info("Fetching all landmarks")
let landmarks = try await client.get("/landmarks")
logger.info("Fetched \(landmarks.count) landmarks")
return landmarks
}
}
Logger is a lightweight value type. Creating one is cheap, it's Sendable, and you don't need to worry about passing it across concurrency domains.
Repeating that subsystem string in every file gets old fast. Centralize it:
extension Logger {
private static let subsystem = Bundle.main.bundleIdentifier!
static let networking = Logger(subsystem: subsystem, category: "Networking")
static let persistence = Logger(subsystem: subsystem, category: "Persistence")
static let auth = Logger(subsystem: subsystem, category: "Auth")
static let ui = Logger(subsystem: subsystem, category: "UI")
}
Now call sites read cleanly and your categories are enumerated in one place:
Logger.networking.info("Fetching all landmarks")
Logger.persistence.debug("Saved \(landmarks.count) landmarks to disk")
I prefer this in most apps. It makes the set of categories a deliberate decision rather than whatever string someone typed at 2am.
The Log Levels
There are five, and they differ in more than just severity. The important distinction is what gets persisted and what survives in a release build.
logger.debug("Cache key computed: \(key)")
logger.info("Fetching landmarks")
logger.notice("User signed in")
logger.error("Failed to decode response: \(error)")
logger.fault("Database migration failed, app state is inconsistent")
Here's how they actually behave:
| Level | Persisted to disk | Survives in release | Use for |
|---|---|---|---|
debug | No | Stripped | Verbose developer detail |
info | Only during log collect | Yes | Flow and context |
notice (default) | Yes | Yes | Significant events |
error | Yes | Yes | Recoverable failures |
fault | Yes | Yes | Bugs and broken invariants |
That table has some practical consequences.
debug is compiled out of release builds and costs essentially nothing in production. Use it freely for the noisy stuff you only want while attached to a debugger.
notice is the default level. logger.log("message") with no level specified is a notice. It persists, so don't put anything high-frequency there.
fault is for "this should be impossible" situations. It persists and it's the level you want to be able to search for when triaging a crash report.
The persistence distinction matters because it determines what's available after the fact. If a user reports a bug that happened an hour ago, only notice and above are still on disk.
How String Interpolation Works Here
This is where os.Logger diverges from what the syntax suggests, and it's also why it's fast.
logger.info("Fetched \(landmarks.count) landmarks")
That doesn't build a String. Logger uses a custom interpolation type (OSLogMessage), and what actually gets written to the log store is the format string plus the arguments, separately, in a compact binary format. The human-readable string is only assembled when something reads the log.
So logging is cheap even when nobody's listening. If a log level is disabled, the arguments are never formatted, and you don't need to guard your log calls behind if isLoggingEnabled.
You also get formatting control that plain interpolation doesn't give you:
logger.info("Request took \(duration, format: .fixed(precision: 2))s")
logger.info("Payload was \(byteCount, format: .byteCount)")
logger.debug("Flags: \(mask, format: .hex)")
logger.info("Retry \(attempt, align: .right(columns: 3))")
What About Privacy
By default, os.Logger redacts interpolated values in release builds:
logger.info("Signed in as \(email)")
In the Console output on a device that isn't attached to a debugger, that renders as:
Signed in as <private>
So the email never reaches the log store in the first place, without you having to remember to strip it.
When a value isn't sensitive, opt in explicitly:
logger.info("Fetching landmark \(id, privacy: .public)")
logger.error("Request failed with status \(statusCode, privacy: .public)")
Be deliberate here. .public on a user identifier is a privacy incident waiting to happen. .public on an HTTP status code is fine and makes your logs useful.
There's a middle option that's underused. .private(mask: .hash) emits a stable hash instead of the value:
logger.info("Loaded profile for \(userID, privacy: .private(mask: .hash))")
You can't recover the user ID, but you can tell that the same user appears in twelve different log lines. It lets you correlate a session without exposing identity.
One important caveat: static strings are always public. Only interpolated values are redacted. So this leaks nothing, because there's no interpolation in it:
logger.info("Sign in failed")
The trap is building the String yourself first. Redaction applies to the values Logger interpolates, so if you hand it a finished String, it sees one opaque value with nothing to redact:
// Wrong: the email is already baked into `message`
let message = "Signed in as \(email)"
logger.info("\(message)")
That prints the address in full. Pass the pieces separately instead, and the privacy rules apply to each one:
// Right: Logger does the interpolation, so `email` is redacted
logger.info("Signed in as \(email)")
// Or, if you need it readable but not identifying
logger.info("Signed in as \(email, privacy: .private(mask: .hash))")
The rule of thumb: the format string should be a literal you typed, and every variable should reach Logger as an interpolation argument.
Reading Your Logs
Writing logs is half of it. Here's how to actually get them back.
In Xcode, the console shows your logs while attached. Note that debug and info are hidden by default in the Xcode console filter, which is a common source of "why isn't my logging working."
In Console.app, connect a device, hit Start, and filter. The filter bar accepts subsystem:com.kylebrowning.landmarks or category:Networking. This is where you'll spend real debugging time, because you can see logs from the whole system alongside yours.
From the terminal is where it gets powerful:
# Live stream from a connected device or the simulator
log stream --predicate 'subsystem == "com.kylebrowning.landmarks"' --level info
# Pull the last hour of persisted logs
log show --predicate 'subsystem == "com.kylebrowning.landmarks"' --last 1h
# Narrow to one category and show errors only
log show --predicate 'subsystem == "com.kylebrowning.landmarks" AND category == "Networking"' --last 30m --level error
The --level flag is inclusive and upward: --level info gives you info and everything above it. Remember that info only appears in log show output when it was captured during collection, per the table above.
Reading Logs From Inside Your App
This is the piece most people don't know exists. OSLogStore lets your app read its own log archive, which is how you build a "attach diagnostics to bug report" feature without shipping a third-party SDK:
import OSLog
func recentLogs(since interval: TimeInterval = 3600) throws -> [String] {
let store = try OSLogStore(scope: .currentProcessIdentifier)
let position = store.position(date: Date().addingTimeInterval(-interval))
let predicate = NSPredicate(
format: "subsystem == %@",
Bundle.main.bundleIdentifier!
)
return try store.getEntries(at: position, matching: predicate)
.compactMap { $0 as? OSLogEntryLog }
.map { entry in
"[\(entry.date)] [\(entry.category)] \(entry.composedMessage)"
}
}
A few practical notes. scope: .currentProcessIdentifier is what works reliably on iOS - the wider .system scope requires entitlements you won't get. Anything logged as .private comes back redacted here too, which is usually what you want when the output is about to be emailed to your support address. And getEntries is synchronous and can be slow over a large window, so keep it off the main thread.
What Not to Log
os.Logger is fast, but it isn't free, and the log store is a fixed-size ring buffer. Flooding it evicts the entries you needed.
Don't log inside a SwiftUI body. It runs far more often than you think, and you'll drown the buffer:
var body: some View {
// Don't do this
let _ = logger.debug("Rendering LandmarkRow")
...
}
Don't log every iteration of a loop over a large collection. Log the summary:
// Instead of logging per-landmark
logger.info("Processed \(landmarks.count) landmarks in \(duration)s")
Don't log request and response bodies at notice or above. If you need them, use debug so they're stripped from release builds entirely.
A Realistic Example
Pulling it together in a service that does real work:
import os
actor LandmarkSyncService {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "Sync"
)
func sync() async throws {
logger.notice("Sync started")
let start = ContinuousClock.now
do {
let remote = try await client.fetchLandmarks()
logger.debug("Received \(remote.count) remote landmarks")
let changes = try await store.merge(remote)
let elapsed = ContinuousClock.now - start
logger.notice("""
Sync finished: \(changes.inserted, privacy: .public) inserted, \
\(changes.updated, privacy: .public) updated in \
\(elapsed.formatted(), privacy: .public)
""")
} catch {
logger.error("Sync failed: \(error.localizedDescription, privacy: .public)")
throw error
}
}
}
Note the shape here. Start and finish at notice so they survive to disk. Intermediate detail at debug so it's free in production. Counts and durations marked .public because they're diagnostics, not user data. The error message .public because a redacted error is a useless error.
Wrapping Up
The rules that matter, condensed:
- One subsystem per app, meaningful categories within it, declared in one
extension Logger. debugfor developer noise,noticefor events you'll want after the fact,errorandfaultfor failures.- Let
Loggerdo the interpolation. Never pre-build theString. - Trust the privacy default. Opt into
.publicdeliberately, and reach for.private(mask: .hash)when you need correlation without exposure. - Use
log streamandlog showfrom the terminal. They're better than the Xcode console. OSLogStoregives you in-app diagnostics for free.
Once this is in place and you want those logs in Datadog, Splunk, or wherever your team looks at production, the provider-agnostic logging post picks up exactly here and builds the forwarding layer without changing a single call site.