How to Check the OS Version at Runtime: iOS and macOS Swift Guide

How to Check the OS Version at Runtime: iOS and macOS Swift Guide

Written by

in

Quick answer: To gate code on an OS version, use if #available(iOS 17, macOS 14, *) or @available. The compiler checks these, so it can warn you about unguarded API use. To read the actual version, use ProcessInfo.processInfo.operatingSystemVersion on any Apple platform, or UIDevice.current.systemVersion on iOS. Avoid uname(), which returns the Darwin kernel version, not the iOS or macOS version.

Why this happens

Developers often reach for uname() to find out which OS their code is running on. It is a C function, so Swift makes you deal with unsafe pointers and a fixed-size C struct. Passing a nil pointer makes the call fail and return -1.

Even when you call it correctly, uname() reports the Darwin kernel release, such as a string starting with 23. or 24.. That number does not match the marketing version a user sees, like iOS 17 or macOS 14. You would need a mapping table, and that table goes stale every year.

Apple gives you better tools for this. The right one depends on your goal:

  • Using an API that only exists on newer systems? Use an availability check (#available / @available).
  • Logging, analytics or showing the version to a user? Read the version from ProcessInfo or UIDevice.
  • Choosing between two Objective-C methods? Check whether the object responds to the selector.

How to fix it

1. Use #available to gate newer APIs (recommended)

In most cases you don’t need the version number. You need to know whether an API is safe to call. Swift’s availability syntax handles this, and the compiler understands it. If you call an iOS 17 API from a target that supports iOS 15 without a check, you get a build error instead of a crash on older devices.

if #available(iOS 17, macOS 14, *) {
    // Safe to use iOS 17 / macOS 14 APIs here
} else {
    // Fallback for older systems
}

The trailing * is required. It means “on any other platform, use the minimum deployment target.”

The check runs at runtime. Because it is part of the language, though, the compiler can confirm that every newer API sits inside a matching check.

Use guard when the rest of a function depends on a newer OS:

func enableNewFeature() {
    guard #available(iOS 17, *) else {
        return
    }
    // Code here can use iOS 17 APIs
}

You can also mark a whole function or type as available only on newer systems. Callers then need their own availability check:

@available(iOS 17, macOS 14, *)
func makeModernConfiguration() {
    // Uses newer APIs freely
}

For the opposite case, where code should run only on older systems, Swift supports #unavailable (Swift 5.6 and later):

if #unavailable(iOS 17) {
    // Only runs below iOS 17
}

2. Use availability checks inside SwiftUI views

if #available works directly inside a SwiftUI body, so you can show a newer view with a fallback:

import SwiftUI

struct EmptyStateView: View {
    var body: some View {
        if #available(iOS 17, macOS 14, *) {
            ContentUnavailableView("No Items", systemImage: "tray")
        } else {
            VStack {
                Image(systemName: "tray")
                Text("No Items")
            }
        }
    }
}

For a modifier that only exists on newer systems, wrap it in a small helper view or a ViewModifier that contains the #available branch. That keeps call sites clean.

3. Read the version number with ProcessInfo

Sometimes you do need the actual numbers, for example in logs, crash reports or a support screen. ProcessInfo works on iOS, iPadOS, macOS, tvOS, watchOS and visionOS. It returns structured integers, so you don’t have to parse strings:

import Foundation

let version = ProcessInfo.processInfo.operatingSystemVersion
print("\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)")

// Human-readable string, intended for display or logs only
print(ProcessInfo.processInfo.operatingSystemVersionString)

To compare against a minimum version without writing your own comparison logic, use isOperatingSystemAtLeast(_:):

let minimum = OperatingSystemVersion(majorVersion: 14, minorVersion: 2, patchVersion: 0)
if ProcessInfo.processInfo.isOperatingSystemAtLeast(minimum) {
    // Running 14.2 or later
}

This is useful when you need to work around a bug fixed in a specific point release. Even then, prefer #available when you are guarding an API, because the compiler can’t reason about isOperatingSystemAtLeast.

Don’t parse operatingSystemVersionString. Its format is not guaranteed.

4. Use UIDevice on iOS if you need a string

In UIKit-based iOS code, UIDevice returns the version as a string:

import UIKit

let systemVersion = UIDevice.current.systemVersion   // e.g. "17.4.1"
let systemName = UIDevice.current.systemName         // e.g. "iOS" or "iPadOS"

This is fine to display. Don’t compare these strings by converting them to a Float, which is a common suggestion. "17.10" becomes 17.1, and patch versions disappear completely. If you must compare version strings, use a numeric string comparison:

let isAtLeast17_2 = UIDevice.current.systemVersion
    .compare("17.2", options: .numeric) != .orderedAscending

In practice, ProcessInfo with isOperatingSystemAtLeast(_:) is cleaner.

5. If you really need uname(), call it correctly

If you want the kernel information itself, the original approach can work. Create a zeroed utsname value and pass it by reference. The fields are fixed-size C character tuples, so convert them to a string:

import Darwin

var info = utsname()
uname(&info)

let release = withUnsafeBytes(of: info.release) { buffer in
    String(decoding: buffer.prefix(while: { $0 != 0 }), as: UTF8.self)
}
print(release) // Darwin kernel release, not the iOS/macOS version

The machine field read the same way gives the hardware identifier. That is a different question from the OS version.

6. Check for the feature instead of the version

For Objective-C-based APIs, you can check whether the method exists instead of guessing from a version number:

import UIKit

let controller = UIViewController()
if controller.responds(to: #selector(UIViewController.show(_:sender:))) {
    // Method exists on this OS
}

For Swift code and modern SDKs, #available is still the better choice because the compiler enforces it. Treat selector checks as a tool for older Objective-C patterns.

You may also see NSFoundationVersionNumber comparisons in older answers. Those constants were never added for newer OS releases. Don’t use them in new code.

How to prevent it

  • Default to #available / @available whenever you’re guarding an API. Let the compiler catch missing checks.
  • Keep your deployment target accurate. Availability warnings are only meaningful relative to it.
  • Use ProcessInfo.operatingSystemVersion for logging and display, not string parsing.
  • Never compare OS versions as Float or Double.
  • Use isOperatingSystemAtLeast(_:) for point-release bug workarounds, and add a comment explaining which bug you’re working around.
  • Avoid uname() for OS version checks. It reports the kernel, not the product version.

FAQ

Is #available a compile-time or runtime check?

It is evaluated at runtime on the user’s device. The compiler also understands it, so it can flag newer APIs used outside a matching check. That is the main advantage over checking version numbers by hand.

Why does uname() return a number like 23 or 24 instead of 17 or 14?

uname() reports the Darwin kernel release that iOS and macOS are built on. Kernel numbers follow their own scheme and don’t match product versions. Use ProcessInfo to get the iOS or macOS version.

Can I use UIDevice.current.systemVersion in a macOS app?

No. UIDevice is part of UIKit and isn’t available in AppKit-based macOS apps. ProcessInfo.processInfo.operatingSystemVersion works on every Apple platform, so it’s the better choice for shared or multiplatform code.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 3.0; this article is shared under the same license.