Struct vs Class in Swift: Making the Right Choice

I have a confession to make. When I first started learning Swift, I used classes for everything. View controllers? Class. Data models? Class. Network service? Class. That tiny little value type helper I wrote? You guessed it — another class. I came from Objective-C, where NSObject was the default answer to almost every design question.

It took me about six months and one particularly painful debugging session — tracking down a bug where a shared User object was being mutated on a background thread while the UI thread was reading it — to truly understand the difference between structs and classes. That crash taught me more than a dozen blog posts could.

Let me save you some of that pain. This post is the deep dive I wish I’d had back then.

1. The Fundamental Difference: Value vs Reference Semantics

Everything else flows from this one distinction. If you understand nothing else, understand this:

Structs are value types. When you assign a struct to a new variable or pass it to a function, Swift makes a copy. The original and the copy are completely independent.

Classes are reference types. When you assign a class instance to a new variable or pass it to a function, both variables point to the same object in memory. Mutate one, and the other sees the change.

Let me show you what that looks like:

// A struct
struct Coordinate {
    var x: Double
    var y: Double
}

// A class
class Location {
    var x: Double
    var y: Double
    
    init(x: Double, y: Double) {
        self.x = x
        self.y = y
    }
}

// --- Value semantics with the struct ---
var coordA = Coordinate(x: 10, y: 20)
var coordB = coordA        // This is a *copy*
coordB.x = 100             

print(coordA.x) // 10  — unchanged
print(coordB.x) // 100 — only B changed

// --- Reference semantics with the class ---
var locA = Location(x: 10, y: 20)
var locB = locA            // Both point to the *same object*
locB.x = 100

print(locA.x) // 100 — A changed too!
print(locB.x) // 100

This difference is not academic. It determines how you reason about your code. With structs, you can look at a function in isolation and know that your data won’t be modified behind your back. With classes, you have to consider every piece of code that holds a reference to the same object.

Why This Matters in Practice

I once worked on an app that displayed a user’s profile. The profile could be edited from multiple screens — settings, the profile page itself, a quick-action sheet. The model was a class.

class UserProfile {
    var displayName: String
    var bio: String
    var avatarURL: URL?
    
    init(displayName: String, bio: String, avatarURL: URL? = nil) {
        self.displayName = displayName
        self.bio = bio
        self.avatarURL = avatarURL
    }
}

The bug manifested as intermittent crashes — the profile image would sometimes show stale data, the bio would revert to an old version after saving. The root cause? Multiple view controllers were holding references to the same UserProfile instance. One screen would start an async save operation, another would optimistically update the local object, and the timing of when the changes propagated was unpredictable.

Changing UserProfile to a struct eliminated that entire category of bug. Each screen worked with its own copy. When a save completed, we’d replace the old struct with the new one atomically. No more phantom mutations.

2. Identity vs Equality

Here’s a subtle but critical distinction that trips up even experienced developers.

With classes, you can ask two questions:

  1. Equality: Do these two objects have the same value? (==)
  2. Identity: Are these two variables pointing to the exact same object? (===)

With structs, the identity question doesn’t apply — because each copy is its own independent instance.

class PersonClass: Equatable {
    let id: UUID
    var name: String
    
    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }
    
    static func == (lhs: PersonClass, rhs: PersonClass) -> Bool {
        lhs.id == rhs.id
    }
}

struct PersonStruct: Equatable {
    var name: String
}

let classA = PersonClass(id: UUID(), name: "Alice")
let classB = classA   // same object

print(classA === classB) // true — same identity
print(classA == classB)  // true — same id (if you implement Equatable)

The === operator is useful in specific situations — like when you need to confirm that two references point to the same object in a tree or graph structure. But in day-to-day code, it’s often a code smell. If you’re checking identity, you’re likely tangling your code with reference semantics in a way that could be avoided.

For structs, Equatable conformance is almost free if all properties are already Equatable:

struct PersonStruct: Equatable {
    var name: String
    var age: Int
}

That’s it. Swift synthesizes the == operator for you. No boilerplate, no == function to write.

3. Inheritance vs Protocol Composition

This is the area where I see the most confusion. Developers coming from class-based languages assume that structs are “worse” because they can’t inherit.

Let me reframe that: Inheritance is not a feature you’re losing — it’s a constraint you’re escaping.

Class inheritance is rigid. A class can have exactly one superclass. That superclass brings along all its properties, methods, and — critically — its invariants. Changing a base class can cascade through dozens of subclasses. I’ve seen BaseViewController hierarchies that are 500+ lines with methods that are overridden, partially overridden, or accidentally not overridden at all.

Swift offers a better path: protocol composition.

protocol Drivable {
    var speed: Double { get set }
    mutating func accelerate(by: Double)
}

protocol Brakeable {
    var isStopped: Bool { get }
    mutating func stop()
}

protocol Refuelable {
    var fuelLevel: Double { get }
    mutating func refuel(amount: Double)
}

// A struct can conform to multiple protocols — no inheritance needed
struct Car: Drivable, Brakeable, Refuelable {
    var speed: Double = 0
    var fuelLevel: Double = 100.0
    var isStopped: Bool { speed == 0 }
    
    mutating func accelerate(by amount: Double) {
        guard fuelLevel > 0 else { return }
        speed += amount
        fuelLevel -= amount * 0.1
    }
    
    mutating func stop() {
        speed = 0
    }
    
    mutating func refuel(amount: Double) {
        fuelLevel = min(100, fuelLevel + amount)
    }
}

struct Bicycle: Drivable, Brakeable {
    var speed: Double = 0
    var isStopped: Bool { speed == 0 }
    
    mutating func accelerate(by amount: Double) {
        speed += amount * 0.5
    }
    
    mutating func stop() {
        speed = 0
    }
    // Bicycle doesn't need Refuelable — no inheritance baggage
}

See what happened? Bicycle gets Drivable and Brakeable without inheriting anything about fuel or engines. In a class hierarchy, you’d either have to put refuel() in a base class (making it available to everything) or create a complex diamond-shaped inheritance tree.

Protocol composition gives you exactly the capabilities you need, nothing more, nothing less. Swift’s protocol-oriented paradigm extends beyond structs — it shapes the entire language, from the standard library to system frameworks. For a comprehensive guide, read Why Swift Is Protocol-Oriented.

The Single Responsibility Benefit

Here’s a practical side effect: when you compose behaviors via protocols, you naturally write smaller, more focused types. A class hierarchy encourages you to put shared functionality in a base class. Over time, that base class accumulates responsibilities.

With protocol composition, you build types from small, testable pieces. Each protocol represents a single responsibility.

// Before: class-based
class NetworkManager {
    func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T { ... }
    func cache(data: Data, for key: String) { ... }
    func log(_ message: String) { ... }
    func retry<T>(operation: () async throws -> T) async throws -> T { ... }
}

// After: protocol-based
protocol Fetchable { func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T }
protocol Cacheable { func cache(data: Data, for key: String) }
protocol Loggable { func log(_ message: String) }
protocol Retryable { func retry<T>(operation: () async throws -> T) async throws -> T }

struct MyNetworkService: Fetchable, Cacheable, Loggable, Retryable {
    // Compose exactly what you need
}

Each protocol can be tested independently. Each can be mocked independently. And you can build a service that only fetches, without any caching or retry logic, by simply not conforming to those protocols.

4. Mutating Methods: Why Structs Handle Mutation Differently

This is the part that confuses newcomers the most. If you declare a struct instance with let, you cannot modify any of its properties — even if those properties are declared with var:

struct Point {
    var x: Double
    var y: Double
}

let origin = Point(x: 0, y: 0)
origin.x = 10 // 🛑 Compiler error: Cannot assign to property 'x' is a 'let' constant

With a class, this would work fine:

class PointClass {
    var x: Double
    var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
}

let originClass = PointClass(x: 0, y: 0)
originClass.x = 10 // ✅ Works — the reference is constant, but the object is mutable

This behavior is intentional and powerful. For structs, the let keyword means “the value itself is constant.” For classes, let means “the reference is constant” — the object it points to can still change.

The same principle applies to methods. To modify a struct’s properties from within a method, you must mark it mutating:

struct Point {
    var x: Double
    var y: Double
    
    mutating func moveBy(dx: Double, dy: Double) {
        x += dx
        y += dy
    }
}

var point = Point(x: 5, y: 10)
point.moveBy(dx: 3, dy: 7) // ✅ Works — point is a var

let fixedPoint = Point(x: 0, y: 0)
fixedPoint.moveBy(dx: 1, dy: 2) // 🛑 Compiler error: Cannot use mutating member on immutable value

What mutating Actually Does

Under the hood, mutating methods work by implicitly reassigning self. When you call a mutating method on a struct, Swift:

  1. Makes a copy of the struct
  2. Modifies the copy
  3. Writes the copy back to the original variable

This is why you can’t call mutating methods on let constants — there’s no variable to write back to.

struct Stack<T> {
    private var items: [T] = []
    
    mutating func push(_ item: T) {
        items.append(item)
    }
    
    @discardableResult
    mutating func pop() -> T? {
        guard !items.isEmpty else { return nil }
        return items.removeLast()
    }
}

This design makes value semantics explicit. You, the caller, know when a mutation is happening because you have to opt into it by using var or inout.

5. Copy-on-Write: When Structs Are Sneakily Efficient

I’ve told you that structs get copied on assignment. If you’re thinking, “That sounds horribly wasteful for large collections,” you’re right — in theory. In practice, Swift uses a clever optimization called copy-on-write (CoW).

Here’s how it works: Arrays, Dictionaries, Strings, and Sets are structs, but they don’t actually copy their underlying storage buffer every time you assign them. They share the buffer. The copy only happens when you mutate one of the references.

var arrayA = [1, 2, 3, 4, 5]
var arrayB = arrayA   // No copy — both share the same buffer

// Only now does the actual copy happen:
arrayB.append(6)      // arrayB gets its own buffer

This means that passing large collections around is cheap. You can have dozens of copies of an array in different parts of your code, and if none of them mutate it, there’s only one buffer in memory.

Implementing CoW in Your Own Structs

You can use this pattern in your own types. The trick is to use a private class as backing storage:

final class Storage<T> {
    var value: T
    init(_ value: T) { self.value = value }
}

struct CowArray<Element> {
    private var storage: Storage<[Element]>
    
    init(_ elements: [Element] = []) {
        self.storage = Storage(elements)
    }
    
    // Access the array — no copy
    var count: Int {
        storage.value.count
    }
    
    // Mutation triggers CoW
    mutating func append(_ element: Element) {
        if !isKnownUniquelyReferenced(&storage) {
            storage = Storage(storage.value)
        }
        storage.value.append(element)
    }
    
    subscript(index: Int) -> Element {
        storage.value[index]
    }
}

The key is isKnownUniquelyReferenced. This function checks whether the reference to storage is the only one pointing to that object. If it is, we can mutate in place. If not, we create a new copy.

I’ll be honest: in most app-level code, you don’t need to implement CoW yourself. The standard library types handle it. But understanding the mechanism helps you appreciate why Swift’s value types can be efficient even when they carry lots of data.

6. Performance: Structs vs Classes

Let’s talk about what actually matters for performance, and what doesn’t.

Where Structs Win

Stack allocation. Small structs (up to about 3-4 words on 64-bit) can be allocated on the stack. Stack allocation is essentially free — it’s just moving the stack pointer. Heap allocation (used for class instances) requires finding free memory, updating allocation bookkeeping, and eventually running deallocation.

No reference counting overhead. Every time you pass a class instance around, Swift increments and decrements its retain count. This is atomic, thread-safe, and has a real cost — especially in concurrent code. Structs have zero reference counting overhead. Understanding how ARC works internally is essential for avoiding leaks — see iOS Memory Management: From ARC to Retain Cycles.

Better cache locality. Structs are stored inline. An array of structs stores the data contiguously in memory. An array of class objects stores pointers contiguously, with each object’s data scattered across the heap. Iterating over a struct array is cache-friendly and fast.

struct PointStruct {
    let x: Double
    let y: Double
}

class PointClass {
    let x: Double
    let y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
}

// Array of structs: 1,000,000 contiguous Point values in memory
let structs = (0..<1_000_000).map { PointStruct(x: Double($0), y: Double($0)) }

// Array of classes: 1,000,000 pointers + 1,000,000 scattered heap allocations
let classes = (0..<1_000_000).map { PointClass(x: Double($0), y: Double($0)) }

I’ve benchmarked this pattern in real apps. Iterating over an array of structs can be 5-10x faster than the equivalent array of class instances, purely due to cache locality.

Where Classes Can Win

Large struct copies. If you have a very large struct (say, 100+ properties) and you pass it around frequently, the copy cost becomes real. In those cases, a class (which copies a pointer instead of all the data) can be more efficient.

Identity tracking. If you need to guarantee that “this instance is the same instance as the one I saw before” — for example, in a graph of objects with circular references — classes handle this naturally.

Objective-C interop. Classes that inherit from NSObject are required for Cocoa frameworks, target-action patterns, KVO, and many UIKit/AppKit APIs.

The Real Advice

Don’t optimize prematurely. In the vast majority of app code, the performance difference between structs and classes is negligible. Write clear, correct code first. Measure second. Optimize third.

The exceptions are hot paths — tight loops, rendering pipelines, audio processing, large data transformations. In those contexts, the struct advantage is real and measurable. SwiftUI’s layout system is one such hot path: every view goes through a propose → respond → position cycle that benefits from value-type efficiency, as explored in Mastering the SwiftUI Layout System.

7. When to Use What: A Decision Framework

After years of making this choice, here’s the framework I use:

Use a Struct When:

  • The type represents a value — a point, a vector, a price, a range, a payment.
  • Copies should be independent — mutating one instance should never affect another.
  • The type has no mutable state — or mutation is explicit and localized.
  • The type doesn’t need identity — you don’t care about “which instance” you have, only its properties.
  • The type is small to moderate in size — a handful of properties.
  • You want automatic thread safety — value semantics eliminate data races from shared mutation.
  • You’re modeling data, not behavior — a user profile, a product, a configuration.

Use a Class When:

  • You need reference semantics — shared mutable state is the correct design (e.g., a cache, a connection pool, a coordinator). ViewModels in MVVM are a classic case: they’re classes precisely because they own observable state, as shown in MVVM with Clean Architecture in iOS.
  • You need inheritance — though consider protocol composition first.
  • The type is very large — and copying it is measurably expensive in your hot path.
  • You need Objective-C interoperability — UIKit delegates, target-action, KVO, Core Data, SwiftData.
  • You need deinit — to clean up resources when the last reference is released.
  • Identity matters — you need === to confirm two references point to the same instance.

The Default

Apple’s guidance, which I fully endorse: Start with a struct. Use a class only when you have a concrete reason. This is the opposite of the Objective-C default, and it takes conscious effort to adopt, but it produces safer, more predictable code.

8. Real-World Migration: From Class to Struct

Let me walk you through a migration I did recently. This was a model type that started as a class and, as the codebase grew, became a source of bugs.

Before: The Class

class CartItem {
    let id: UUID
    var productName: String
    var quantity: Int
    var unitPrice: Decimal
    var notes: String?
    
    var totalPrice: Decimal {
        unitPrice * Decimal(quantity)
    }
    
    init(id: UUID = UUID(), productName: String, quantity: Int, unitPrice: Decimal, notes: String? = nil) {
        self.id = id
        self.productName = productName
        self.quantity = quantity
        self.unitPrice = unitPrice
        self.notes = notes
    }
}

// Used like this:
let item = CartItem(productName: "Widget", quantity: 2, unitPrice: 9.99)
item.quantity += 1 // Mutates in place

The problems:

  • Multiple view controllers held references to the same CartItem. Changing quantity on one screen would silently update another.
  • Thread safety was a concern — the checkout process mutated items on a background queue.
  • Testing required careful setup to avoid shared state between test cases.

After: The Struct

struct CartItem: Equatable, Identifiable {
    let id: UUID
    var productName: String
    var quantity: Int
    var unitPrice: Decimal
    var notes: String?
    
    var totalPrice: Decimal {
        unitPrice * Decimal(quantity)
    }
}

But wait — the code did item.quantity += 1. That won’t compile if item is a let constant. The fix was to make the mutation explicit:

// Before (class):
func addOne(to item: CartItem) {
    item.quantity += 1
}

// After (struct):
func addOne(to item: CartItem) -> CartItem {
    var copy = item
    copy.quantity += 1
    return copy
}

// Or, with inout:
func addOne(to item: inout CartItem) {
    item.quantity += 1
}

This forced us to think about where mutations were happening and why. We found several places where mutation was an accident of the reference semantics — the code didn’t actually need to modify the original, it just happened to because it could.

The Result

  • Zero bugs from shared mutation in the shopping cart after the migration.
  • Thread safety improved because each part of the system worked with its own copies.
  • The CartItem struct gained automatic Equatable conformance, which we used for diffing in the UI.
  • The code became more explicit about when and where mutations occur.

9. Special Cases: Actors, Sendable, and Concurrency

Swift’s concurrency model adds another dimension to the struct vs class decision.

Actors are reference types that protect their mutable state with a mutual-exclusion guarantee. If you need shared mutable state in a concurrent context, consider an actor over a plain class.

Sendable is a protocol that indicates a type is safe to pass across concurrency domains. Structs are implicitly Sendable if all their properties are Sendable. Classes are not — they must be explicitly marked, and only under specific conditions. Swift 6’s strict concurrency checking turns these choices into compile-time enforcement — for the full picture, see Struct vs Class in Swift 6: Concurrency-Safe Choices, which deep-dives into Sendable, actors, @MainActor isolation, and migration pain points.

struct User: Sendable {        // ✅ Implicitly Sendable — all properties are Sendable
    let id: UUID
    let name: String
}

class MutableUser {             // ❌ Not Sendable — mutable state is a data-race risk
    var name: String
    init(name: String) { self.name = name }
}

final class SafeUser: Sendable { // ✅ Fine — immutable
    let id: UUID
    let name: String
    init(id: UUID, name: String) { self.id = id; self.name = name }
}

actor UserActor {              // ✅ Protected by actor isolation
    private var name: String
    init(name: String) { self.name = name }
    
    func updateName(_ newName: String) {
        self.name = newName
    }
}

If you’re writing concurrent code, structs give you a significant advantage: they’re naturally safe to pass between threads. No locks, no queues, no data races from shared mutation.

The Sendable Warning

In Swift 6 (strict concurrency checking), the compiler will warn or error when you pass a non-Sendable class instance across concurrency boundaries. This catches data-race bugs at compile time. Structs rarely trigger these warnings, which is another reason to prefer them by default.

10. Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting That Array Is a Struct

var items = [1, 2, 3]
func appendFour(to array: [Int]) {
    var array = array // This is a copy
    array.append(4)
}
appendFour(to: items)
print(items) // [1, 2, 3] — unchanged!

If you expected items to be modified, you forgot that Array is a struct. Use inout to modify the original.

Pitfall 2: Copying Large Structs Unnecessarily

Every time you pass a struct to a function, Swift may copy it. For large structs, this can add up:

struct LargeModel {
    var massiveArray: [Double]  // CoW helps here
    var imageData: Data         // So does CoW
    var metadata: [String: Any] // And here
}

This is less of a problem than you might think, thanks to CoW. But if you’re passing a large struct through many layers of functions, consider using inout:

func processLargeModel(_ model: inout LargeModel) {
    // Work directly on the original — no copy
}

Pitfall 3: Using Classes When You Mean Struct

This is the most common one. If you’re writing a data model and you don’t need inheritance, identity, or shared mutable state, use a struct. The compiler will thank you, your future self will thank you, and your teammates will thank you.

Pitfall 4: Making All Properties var When You Don’t Need To

With structs, immutable properties (let) are your friend. They guarantee that a value won’t change after creation. With classes, let properties only prevent reassignment of the pointer — the object can still be mutated.

struct ImmutableUser {
    let id: UUID
    let name: String
}

// If you have a user and want to change the name,
// you create a new struct with a different name.
// This is explicit and intentional.

This pattern is sometimes called “persistent data” or “immutable models,” and it eliminates entire classes of bugs.

Key Takeaways

  • Value vs reference semantics is the core difference. Structs copy on assignment; classes share a reference. Everything else follows from this.
  • Start with structs as your default. Use classes only when you have a concrete, justified reason — shared state, inheritance, ObjC interop, or identity needs.
  • Mutation is explicit with structs. mutating methods and inout parameters make it clear when data is being modified. This is a feature, not a limitation.
  • Copy-on-write makes structs efficient. Standard library types like Array and Dictionary use CoW to avoid unnecessary copies. You can implement it in your own types too.
  • Protocol composition replaces inheritance. You don’t need class inheritance when you can compose behaviors through protocols. This leads to smaller, more focused types.
  • Structs are naturally thread-safe. Value semantics eliminate data races from shared mutation. Combined with Sendable conformance, structs are the safe choice for concurrent code.
  • Large migrations are worth the effort. Moving from class-based models to structs can eliminate entire categories of bugs, especially around shared mutable state.

The struct vs class decision is one of the first you make when designing any Swift type. Getting it right pays dividends across your entire codebase. And the good news? Swift makes the right choice easy — just start with a struct, and you’ll almost never need to change it.