Struct vs Class in Swift: How to Choose

Choosing between struct and class is the first design decision for almost every Swift type, and it is expensive to reverse once other code depends on it. This guide gives you a default, the specific criteria that override it, and realistic scenarios with the reasoning spelled out.

Quick answer: Use a struct by default. Switch to a class only when you need something a value cannot give you: one shared, mutable instance that several owners observe (identity your code controls), cleanup in deinit with shared ownership, subclassing or Objective-C interop, or a framework that requires a class, such as @Observable or SwiftData's @Model. If that shared mutable state is touched from concurrent tasks, use an actor instead of a plain class.

Difficulty: Intermediate

You'll understand this better if you know: how to declare structs and classes, the difference between value and reference semantics, and the basics of async/await for the concurrency criteria.

1. What You Are Really Choosing

The keyword decides what happens when an instance is assigned, passed to a function, or captured by a closure. A struct gives every holder its own logical copy. A class gives every holder a reference to one shared instance. Identity, performance, concurrency safety, testability, and API stability all follow from that single difference.

The mechanics are covered in the articles linked above. This guide is about the decision, and in practice you have more than two options:

  • struct: a value type. The default for data.
  • enum: a value type for a closed set of cases, often with associated values.
  • final class: a reference type that cannot be subclassed. The usual form when you need a class but not inheritance.
  • actor: a reference type whose mutable state is isolated from data races. The right tool for shared mutable state that concurrent tasks access.
  • noncopyable struct (~Copyable): a value type that cannot be copied, which gives you unique ownership and a deinit without reference counting (SE-0390, Swift 5.9).

2. Decision Criteria at a Glance

CriterionStructClass
Assignment and passingCopies the value; changes stay localCopies the reference; changes are visible to every holder
IdentityNone beyond its contents; model domain identity with an id property (Identifiable)Built in; === and ObjectIdentifier distinguish instances
Mutation controllet freezes the whole value; mutating and inout make writes explicitlet freezes only the reference; any holder can change var properties
LifecycleNo deinit (unless ~Copyable)deinit runs when the last strong reference goes away
InheritanceProtocols onlySingle class inheritance plus protocols
Weak and unowned referencesNot possibleSupported (delegates, parent back-references)
Objective-C and framework subclassingCannot be @objcRequired: @objc types, KVO, and UIViewController subclasses must inherit from NSObject
Framework requirementsSwiftUI View, @State values, Codable payloads@Observable, ObservableObject, SwiftData @Model
Concurrency (Swift 6)Non-public structs are implicitly Sendable when all stored properties areSendable only if final with immutable Sendable properties; otherwise use an actor or a lock
Memory modelStored inline; a copy retains each reference-counted fieldHeap allocation plus atomic reference counting; retain cycles possible
Method dispatchStatic, except through protocol requirementsDynamic (vtable) unless final, private, or devirtualized by the optimizer
TestingEquatable makes whole-value assertions trivialShared instances can leak state between tests
Changing your mind laterStruct to class can compile cleanly and silently change behaviorClass to struct breaks callers that mutate through let or rely on sharing

3. The Trade-offs in Detail

Identity: who controls it?

Apple's guidance in Choosing Between Structures and Classes draws the line at control. Use a class when your code must control the identity of the thing it models: a file handle, a network connection, the single in-memory source of truth for a screen. Use a struct when identity is owned elsewhere, such as a row on a server, and you only carry its identifier.

A useful test: if two variables contain equal data, are they the same thing? If yes, you are modeling a value. If they are the same thing only when they are literally the same instance, you need reference identity.

Performance: allocation, reference counting, and copies

  • Classes are heap-allocated and reference-counted, and retain/release are atomic operations. The optimizer can sometimes place a non-escaping instance on the stack, but you should not design around that.
  • Structs have no allocation of their own; they live inline in a variable, an array buffer, or an enclosing object. However, copying a struct retains every reference-counted field it contains: class references and the heap buffers behind Array, Dictionary, Set, and non-small String values. A struct with ten such fields pays ten retains per copy, where a class reference pays one.
  • Existentials: a value stored in an any Protocol box is kept inline only if it fits in the existential's three-word buffer; larger structs are moved to a heap allocation.
  • Dispatch: methods on a non-final class are dispatched through a vtable unless the compiler can prove there is no override. Mark classes final unless you designed them for subclassing.

Neither kind is faster in general. When a type sits on a hot path, profile with Instruments (Allocations and Time Profiler) and look for swift_retain, swift_release, and malloc traffic before changing the type.

Copy-on-write: what you get for free

The standard library collections and String use copy-on-write: copying is O(1), and the buffer is duplicated only when someone mutates a buffer that is shared. A struct built from these fields inherits that behavior automatically, so you do not need to implement copy-on-write for ordinary models.

Copy-on-write makes copies cheap, not mutation through copies. If another copy still holds the buffer when you mutate, you pay a full O(n) copy. In hot loops, mutate in place with mutating methods or inout parameters instead of keeping a second copy alive. You only need to write copy-on-write yourself when a struct's storage is a class, as shown in section 7.

Concurrency and Sendable

In the Swift 6 language mode, data-race safety is checked at compile time, and value types have a large advantage: a struct whose stored properties are all Sendable can cross task and actor boundaries freely because each side gets its own copy. A class can conform to Sendable only if it is final, has no superclass other than NSObject, and all of its stored properties are immutable and Sendable.

Swift
struct PreferencesSnapshot: Sendable {   // OK: a value type whose stored properties are Sendable
    var theme = "light"
    var fontSize = 14
}

final class Preferences: Sendable {
    var theme = "light"   // error: stored property 'theme' of 'Sendable'-conforming class 'Preferences' is mutable
}

For mutable state that concurrent code must share, use an actor. If callers need synchronous access, a final class that guards its state with Mutex from the Synchronization module (iOS 18 and macOS 15 or later) can conform to Sendable without @unchecked. Reserve @unchecked Sendable for types whose thread safety you have verified another way.

Lifecycle and ownership

A class's deinit runs when the last strong reference disappears, which makes classes the natural owner of shared resources. A copyable struct cannot declare a deinit at all. A ~Copyable struct can, because the compiler guarantees a single owner. Only classes support weak and unowned references, which is why delegate properties and parent back-references require class instances. Retain cycles are also a class-only problem (closures aside).

Testability

Value types are the easiest code to test: build an input, call a function, and compare the result to an expected value with ==. Shared class instances, especially singletons, carry state from one test to the next, and Swift Testing runs tests in parallel by default, so shared mutable state turns into flaky tests. For test doubles, protocols work equally well with structs and classes. Subclass-and-override mocking only works with non-final classes and couples tests to implementation details.

Framework requirements and API compatibility

Some APIs decide for you. The Observation macro rejects structs with "'@Observable' cannot be applied to struct type", SwiftData's @Model does the same, and ObservableObject is a class-only protocol. UIKit and AppKit expect you to subclass classes like UIViewController. A struct cannot be marked @objc at all, and a class can be @objc, observed with KVO, or adopt NSCoding only if it inherits from NSObject.

Changing the choice later is a breaking change for every caller. Going from class to struct is the loud direction: code that mutates through a let constant stops compiling. Going from struct to class is the dangerous direction: var copy = original; copy.name = "x" still compiles, but now it mutates the original. In a framework built with library evolution enabled, either change also breaks ABI.

4. Realistic Scenarios

Scenario 1: Decoding and editing API data → struct

An order comes from a server, the user edits a draft, and the app sends the change back. The server owns the order's identity; the app only carries its id.

Swift
struct LineItem: Codable, Hashable, Sendable {
    let sku: String
    var quantity: Int
    var unitPriceCents: Int
}

struct Order: Codable, Hashable, Identifiable, Sendable {
    let id: String              // identity owned by the server
    var items: [LineItem]

    var totalCents: Int {
        items.reduce(0) { $0 + $1.quantity * $1.unitPriceCents }
    }

    mutating func setQuantity(_ quantity: Int, for sku: String) {
        guard let index = items.firstIndex(where: { $0.sku == sku }) else { return }
        items[index].quantity = quantity
    }
}

let saved = Order(id: "A-1001", items: [
    LineItem(sku: "pen", quantity: 2, unitPriceCents: 150),
    LineItem(sku: "pad", quantity: 1, unitPriceCents: 400)
])

var draft = saved                  // an independent copy for editing
draft.setQuantity(5, for: "pen")

print(saved.totalCents)            // 700
print(draft.totalCents)            // 1150
print(saved == draft)              // false: compared by value
Output✓ Tested · Swift 6.4
700
1150
false

Why struct: the draft is a free, independent copy, so cancelling an edit is just discarding the variable. saved == draft tells you whether there are unsaved changes. Codable, Hashable, and Sendable come with almost no code, and the values can move between actors safely.

Scenario 2: State shared by several SwiftUI views → @Observable final class

A cart badge in the toolbar, the cart screen, and the checkout button must all reflect the same cart. That is one instance with identity your code controls.

Swift
import Observation

struct CartItem: Identifiable, Hashable, Sendable {
    let id: Int
    var name: String
    var quantity: Int
}

@MainActor
@Observable
final class CartModel {
    private(set) var items: [CartItem] = []

    var itemCount: Int { items.reduce(0) { $0 + $1.quantity } }

    func add(_ item: CartItem) {
        items.append(item)
    }
}

let cart = CartModel()
let headerView = cart   // two screens hold the same instance
let listView = cart

listView.add(CartItem(id: 1, name: "Pen", quantity: 2))
print(headerView.itemCount)   // 2: every holder sees the change
print(headerView === listView) // true: one identity

Why class: every view must observe the same mutable instance, and @Observable requires a class anyway (iOS 17 and macOS 14 or later). The class is final because nothing should subclass it, and @MainActor because SwiftUI reads it on the main actor. Notice that the items inside are still structs: the class owns the state, the values describe it.

Scenario 3: Wrapping a resource with a lifecycle → final class or noncopyable struct

A database connection is shared by a repository, a sync engine, and a migration runner, and it must close exactly once. A temporary file descriptor, on the other hand, has exactly one owner at a time.

Swift
// Shared ownership: several components hold the same connection,
// and it closes when the last strong reference goes away.
final class DatabaseConnection {
    let path: String

    init(path: String) {
        self.path = path
        print("open \(path)")
    }

    func query(_ sql: String) -> String { "result of \(sql)" }

    deinit { print("close \(path)") }
}

// Unique ownership: exactly one owner at a time, no reference counting.
struct FileDescriptor: ~Copyable {
    let raw: Int32

    init(raw: Int32) {
        self.raw = raw
        print("acquire fd \(raw)")
    }

    consuming func close() {
        print("close fd \(raw)")
        discard self              // cleanup already done: skip deinit
    }

    deinit { print("deinit releases fd \(raw)") }
}

func run() {
    let connection = DatabaseConnection(path: "app.sqlite")
    let repository = connection      // a second reference to the same connection
    print(repository.query("SELECT 1"))

    let log = FileDescriptor(raw: 3)
    log.close()                       // consumes `log`; any later use is a compile error

    let temp = FileDescriptor(raw: 4)
    _ = temp.raw
    // temp's deinit and the connection's deinit both run before run() returns.
    // Swift may end a lifetime after its last use, so don't depend on their order.
}

run()
Output✓ Tested · Swift 6.4
open app.sqlite
result of SELECT 1
acquire fd 3
close fd 3
acquire fd 4
close app.sqlite
deinit releases fd 4

Why: shared ownership with deterministic cleanup is the textbook case for a class with deinit. When ownership is unique, a ~Copyable struct gives you the same cleanup guarantee without reference counting, and misuse such as using a value after it was consumed fails at compile time with "used after consume".

Scenario 4: A cache shared by concurrent tasks → actor

Many tasks download images and write thumbnails into one cache. A struct cannot be shared, because each task would mutate its own copy. A plain class with mutable properties would race, and the Swift 6 compiler rejects sharing it.

Swift
actor ThumbnailCache {
    private var storage: [String: [UInt8]] = [:]

    func thumbnail(for key: String) -> [UInt8]? { storage[key] }
    func insert(_ bytes: [UInt8], for key: String) { storage[key] = bytes }
    var count: Int { storage.count }
}

let cache = ThumbnailCache()

await withTaskGroup(of: Void.self) { group in
    for i in 0..<100 {
        group.addTask {
            await cache.insert([UInt8(i)], for: "photo-\(i)")
        }
    }
}

print(await cache.count)   // 100: no lost updates, no data race
Output✓ Tested · Swift 6.4
100

Why actor: it has reference semantics like a class, but the compiler serializes access to its state, so there are no lost updates and no locks to get wrong. The cost is that every access from outside is await. If that is unacceptable, use a final class with a Mutex.

5. Decision Checklist

Go through the questions in order and stop at the first "yes":

  1. Does an API you must use require a class? @Observable, ObservableObject, SwiftData @Model, UIKit or AppKit subclassing, @objc, KVO, or a weak delegate. Use a class, final unless subclassing is required.
  2. Is it mutable state shared by concurrent tasks? Use an actor, or a final class with a Mutex when callers need synchronous access.
  3. Must several owners see and mutate the same instance, and does your code control its identity? Use a final class.
  4. Does it own a resource that must be released exactly once? Use a final class with deinit for shared ownership, or a ~Copyable struct for unique ownership.
  5. Do you want inheritance to share behavior? Try protocols with default implementations on structs first; use class inheritance only for hierarchies you do not control.
  6. Is it a large value that is mutated on a hot path while copies exist? Keep a struct API and add copy-on-write storage, but only after profiling shows the cost.
  7. None of the above? Use a struct, or an enum if the values form a closed set of cases.

6. Wrong Reasons to Pick One

  • "Classes avoid copying, so they are faster." Copying an array or string field is O(1) thanks to copy-on-write, while every class instance costs a heap allocation and atomic reference counting. Measure before assuming.
  • "Structs are always faster." A struct with many reference-counted fields, or a large struct stored in any Protocol boxes, can be slower to copy than a single class reference.
  • "It has an ID, so it is an entity and needs a class." A server-assigned id is identity you do not control. Make the type an Identifiable struct.
  • "A function needs to modify it." That is what inout and mutating are for, and the & at the call site makes the mutation visible:
Swift
struct Invoice {
    var subtotalCents: Int
    var discountCents = 0
}

// "I need a function that changes it" is not a reason to use a class.
func applyLoyaltyDiscount(to invoice: inout Invoice) {
    invoice.discountCents = invoice.subtotalCents / 10
}

var invoice = Invoice(subtotalCents: 5_000)
applyLoyaltyDiscount(to: &invoice)   // the mutation is visible at the call site
print(invoice.discountCents)         // 500
Output✓ Tested · Swift 6.4
500
  • "I want to share code between types." Protocol extensions share implementations across structs, enums, and classes without a base class.
  • "I will need to mock it." Define a protocol for the dependency; structs conform just as well as classes.
  • "let keeps it immutable." Only for structs. For a class, let fixes the reference while every var property stays writable by any holder.
  • "Everything is an object in the language I came from." Swift's standard library is built mostly from structs and enums. Swift rewards modeling data as values.

7. When to Mix Both

Struct API, class storage

When a value type needs storage that is itself a reference, such as manually managed memory, keep the public type a struct and hide a private final class inside it. Use isKnownUniquelyReferenced(_:) to copy the storage only when a shared buffer is about to be mutated.

Swift
// Public API: a value type. Storage: a private final class that owns raw memory.
struct PixelBuffer {
    private final class Storage {
        let pixels: UnsafeMutableBufferPointer<UInt8>

        init(count: Int) {
            pixels = .allocate(capacity: count)
            pixels.initialize(repeating: 0)
        }

        init(copying other: Storage) {
            pixels = .allocate(capacity: other.pixels.count)
            _ = pixels.initialize(fromContentsOf: other.pixels)
        }

        deinit { pixels.deallocate() }   // only a class (or ~Copyable type) gets deinit
    }

    private var storage: Storage
    let width: Int

    init(width: Int, height: Int) {
        self.width = width
        storage = Storage(count: width * height)
    }

    subscript(x: Int, y: Int) -> UInt8 {
        get { storage.pixels[y * width + x] }
        set {
            if !isKnownUniquelyReferenced(&storage) {
                print("copy-on-write: duplicating \(storage.pixels.count) bytes")
                storage = Storage(copying: storage)
            }
            storage.pixels[y * width + x] = newValue
        }
    }
}

var original = PixelBuffer(width: 1000, height: 1000)
original[0, 0] = 255     // storage is unique: mutated in place

var edited = original    // O(1): both values share one storage object
edited[1, 1] = 128       // first write to shared storage copies it once
edited[2, 2] = 64        // unique again: no copy

print(original[1, 1], edited[1, 1])   // 0 128
Output✓ Tested · Swift 6.4
copy-on-write: duplicating 1000000 bytes
0 128

Callers get value semantics and O(1) copies; the class exists only because the storage needs a deinit to free its memory. This is the same technique the standard library collections use internally.

Class owner, struct state

The most common mix is the one in Scenario 2: a single class (or actor) owns the mutable source of truth, and everything it stores and hands out is a struct. Views and background tasks receive value snapshots they can read, compare, and send across actors without coordinating with the owner.

The pitfall: references hidden inside values

A struct that stores a mutable class instance no longer has value semantics, even though it is declared with struct:

Swift
final class Tags {
    var names: [String] = []
}

struct Post {
    var title: String
    let tags = Tags()          // a reference hidden inside a value
}

let draft = Post(title: "Draft")
var published = draft
published.title = "Published"
published.tags.names.append("swift")

print(draft.title)             // Draft: the struct part was copied
print(draft.tags.names)        // ["swift"]: the class part is shared
Output✓ Tested · Swift 6.4
Draft
["swift"]

If you embed a reference in a value type, make the referenced type immutable, wrap it in copy-on-write storage, or admit that the outer type has reference semantics and make it a class.

8. Key Points

  • Default to a struct; the burden of proof is on the class.
  • Use a class for identity your code controls, shared ownership with deinit, Objective-C or framework subclassing, and APIs that require one, such as @Observable and SwiftData @Model.
  • Use an actor, not a plain class, for mutable state shared by concurrent tasks.
  • Consider a ~Copyable struct when you need unique ownership and deterministic cleanup.
  • Neither kind is faster in general: classes pay for allocation and reference counting, and struct copies pay for every reference-counted field. Profile before switching.
  • Mark classes final unless they are designed for subclassing.
  • Switching from struct to class later can compile cleanly while silently changing behavior, so decide deliberately.
  • Mixing is normal: class owners with struct state, and struct APIs with private class storage.

9. Final Summary

The struct-versus-class decision is a decision about semantics: whether holders get independent copies or share one instance. Structs win by default because local reasoning, cheap Sendable conformance, and equality-based testing come almost for free, and the standard library's copy-on-write collections remove most of the copying cost.

Classes earn their place when identity and shared ownership are the point: an observable model several views watch, a connection that must close exactly once, a framework type you are required to subclass. When that shared state crosses concurrency boundaries, an actor is usually better than a class, and when ownership is unique, a noncopyable struct can replace a class entirely. Walk through the checklist in order, and if nothing on it applies, write a struct.