# Struct vs Class in Swift: How to Choose

Source: https://devdocs10.com/articles/swift/swift-struct-vs-class  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

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](/articles/swift/swift-structs) and classes, the difference between [value and reference semantics](/articles/swift/swift-value-vs-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](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0390-noncopyable-structs-and-enums.md), Swift 5.9).

## 2. Decision Criteria at a Glance

| Criterion | Struct | Class |
| --- | --- | --- |
| Assignment and passing | Copies the value; changes stay local | Copies the reference; changes are visible to every holder |
| Identity | None beyond its contents; model domain identity with an `id` property (`Identifiable`) | Built in; `===` and `ObjectIdentifier` distinguish instances |
| Mutation control | `let` freezes the whole value; `mutating` and `inout` make writes explicit | `let` freezes only the reference; any holder can change `var` properties |
| Lifecycle | No `deinit` (unless `~Copyable`) | `deinit` runs when the last strong reference goes away |
| Inheritance | Protocols only | Single class inheritance plus protocols |
| Weak and unowned references | Not possible | Supported (delegates, parent back-references) |
| Objective-C and framework subclassing | Cannot be `@objc` | Required: `@objc` types, KVO, and `UIViewController` subclasses must inherit from `NSObject` |
| Framework requirements | SwiftUI `View`, `@State` values, `Codable` payloads | `@Observable`, `ObservableObject`, SwiftData `@Model` |
| Concurrency (Swift 6) | Non-public structs are implicitly `Sendable` when all stored properties are | `Sendable` only if `final` with immutable `Sendable` properties; otherwise use an actor or a lock |
| Memory model | Stored inline; a copy retains each reference-counted field | Heap allocation plus atomic reference counting; retain cycles possible |
| Method dispatch | Static, except through protocol requirements | Dynamic (vtable) unless `final`, `private`, or devirtualized by the optimizer |
| Testing | `Equatable` makes whole-value assertions trivial | Shared instances can leak state between tests |
| Changing your mind later | Struct to class can compile cleanly and silently change behavior | Class 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](https://developer.apple.com/documentation/swift/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](/articles/swift/swift-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](/articles/swift/swift-actors). 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](https://developer.apple.com/documentation/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 with Swift 6.4):

```text
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 with Swift 6.4):

```text
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 with Swift 6.4):

```text
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 with Swift 6.4):

```text
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(_:)`](https://developer.apple.com/documentation/swift/isknownuniquelyreferenced(_:)-98zpp) 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 with Swift 6.4):

```text
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 with Swift 6.4):

```text
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.
