In Swift, zombie objects is a debugging term for use-after-free bugs: your code tries to use an instance that has already been destroyed. Swift’s Automatic Reference Counting (ARC) destroys an object when its last strong reference goes away, but some mistakes can still leave you holding a reference to a dead object: an unowned reference to an object that died first, an unsafe pointer or Unmanaged reference that outlives its target, or two threads racing on the same reference.

The name comes from Xcode’s Zombie Objects diagnostic. When it’s on, Objective-C objects are not freed when they die. Instead, they’re turned into “zombies” that log message sent to deallocated instance the moment anything touches them. That makes the bug easy to find while debugging. Without that diagnostic, and especially in production, a use-after-free might crash with EXC_BAD_ACCESS, or it might silently read whatever now lives at that address and corrupt your data.

Swift’s safety features prevent most memory errors, but understanding how ARC decides when objects die is still essential for robust apps.

A zombie memory chip above an empty socket with a stale pointer and warning symbol

Zombie objects and stale references to deallocated memory

What Are Zombies in Swift?

A “zombie” is not a real type in your app; it’s a debugging aid. ARC destroys an object when its strong reference count reaches zero. If you later reach that object through a reference that’s still lying around, you have a use-after-free.

How that plays out depends on the kind of reference you used:

How you reached the dead object What happens
weak Nothing bad. The reference became nil when the object died.
unowned (the default, “safe” form) A guaranteed crash at the point of access, with a clear error.
unowned(unsafe), Unmanaged, unsafe pointers Undefined behavior: a crash, garbage values, or silent corruption.
Over-released Objective-C object Undefined behavior, or a clear zombie message if Zombie Objects is enabled.

Notice that only the last two rows are truly dangerous. Plain Swift code that doesn’t use unsafe APIs can’t silently read freed memory.

Swift’s own (friendly) zombies

Here’s an interesting detail: Swift’s regular unowned references are implemented with a zombie-like trick of their own. Every Swift object has a separate counter for unowned references. When the last strong reference goes away, the object is deinitialized (deinit runs and its properties are released), but its memory isn’t freed while unowned references still point at it. Any access through one of those references sees that the object is dead and stops the program:

Fatal error: Attempted to read an unowned reference but object 0x100ab5e10 was already destroyed

(Older Swift versions say “was already deallocated”.) That’s why a bad unowned access crashes reliably, exactly where the mistake is, instead of corrupting memory. unowned(unsafe) skips that bookkeeping, which is also why it’s unsafe.

Zombies vs. retain cycles

ARC frees objects when their strong count hits zero. Retain cycles, where two or more objects hold each other strongly, prevent that from happening and cause memory leaks, not zombies. The two bugs are opposites: a leak is an object that lives too long, a zombie is a reference that lives longer than its object.

They are connected in practice, though. A common way to create a zombie is to “fix” a leak by changing a reference to unowned without checking that the lifetimes really line up. That removes the leak but can introduce a crash. Fix cycles by thinking about ownership (usually with weak), not by guessing with unowned.

Causes of Zombie Objects in Swift

1. Misused unowned

unowned is a promise: “the object I point to will be alive whenever I use this reference.” If the promise is wrong, the access crashes.

This parent/child setup is the textbook correct use of unowned:

class Parent {
    var child: Child?
    init() { child = Child(parent: self) }
}

class Child {
    unowned let parent: Parent
    init(parent: Parent) { self.parent = parent }
    func greet() { print("parent is", parent) }
}

The parent owns the child, so as long as the child is only ever reached through its parent, the parent is guaranteed to be alive. The bug appears the moment someone else keeps the child around:

func makeChild() -> Child {
    Parent().child! // the Parent is released at the end of this function
}

let child = makeChild()
child.greet() // 💥 Attempted to read an unowned reference but object ... was already destroyed

Nothing in Child changed. The relationship stopped being true because of code somewhere else. That’s what makes unowned risky in a growing codebase: the guarantee depends on how every caller uses the type.

Use unowned only when the lifetime relationship is strict and easy to see (for example, an object that is only ever reachable through its owner). Otherwise, prefer weak. The cost is an optional unwrap.

2. [unowned self] in escaping closures

The same mistake shows up most often in closures:

final class ProfileViewController: UIViewController {
    func loadProfile() {
        api.fetchProfile { [unowned self] profile in
            self.render(profile) // 💥 if the screen was closed before the request finished
        }
    }
}

[unowned self] is fine for closures that can’t outlive self, such as a closure stored in a property of self and called only by self. Network callbacks, timers, notifications, and animations can all finish after the user has left the screen, so use [weak self] for those.

3. Unsafe pointers that outlive their target

Unsafe pointers don’t keep anything alive. A pointer is only valid while the memory it points to exists, and Swift often gives you pointers that are only valid for a single call:

var numbers = [1, 2, 3]
let p = UnsafePointer<Int>(&numbers)
print(p.pointee) // ❌ undefined behavior: p is already dangling

The &numbers conversion only produces a pointer for the duration of the init call. The compiler warns about this one:

warning: initialization of 'UnsafePointer<Int>' results in a dangling pointer

Treat that warning as an error. Use the scoped APIs instead, and never let the pointer escape the closure:

numbers.withUnsafeBufferPointer { buffer in
    print(buffer[0]) // ✅ valid only inside this closure
}

4. Getting Unmanaged ownership wrong

Unmanaged lets you step outside ARC, usually to pass a Swift object through a C API as a void * context pointer, or to handle Core Foundation objects whose ownership the compiler can’t infer. You’re then responsible for the reference counting:

  • passUnretained(_:) hands out a pointer without retaining the object. If the object dies before the C code calls you back, the callback gets a zombie.
  • passRetained(_:) retains the object, and you must balance it later with exactly one release() or takeRetainedValue().
  • takeRetainedValue() consumes a +1 reference. Using it on a reference you don’t own over-releases the object, which can destroy it while other code still uses it.
  • takeUnretainedValue() doesn’t consume anything. Using it on a reference you do own leaks.

A typical callback-based C API:

final class Monitor {
    func start() {
        // ✅ Keep the Monitor alive until the C library is done with it.
        let context = Unmanaged.passRetained(self).toOpaque()
        c_library_start(context) { context in
            let monitor = Unmanaged<Monitor>.fromOpaque(context!).takeUnretainedValue()
            monitor.handleEvent()
        }
    }

    func stop() {
        c_library_stop { context in
            // Balance the passRetained from start().
            Unmanaged<Monitor>.fromOpaque(context!).release()
        }
    }
}

(c_library_start and c_library_stop stand in for whatever C API you’re calling.) With passUnretained here, any event that arrives after the Monitor is released would touch a dead object. When bridging Core Foundation APIs, follow the “Create Rule” and “Get Rule” from Apple’s Core Foundation memory management guide: functions with Create or Copy in the name return +1 references, everything else returns +0.

5. Data races on a reference

Retain and release are atomic in Swift, so two threads can safely retain the same object. What isn’t safe is two threads reading and writing the same variable at the same time:

final class Cache {
    var current: Item? // ❌ read on one thread, replaced on another
}

Reading current means “load the pointer, then retain it”. If another thread replaces current between those two steps, the old object can be released and destroyed before the first thread retains it. The first thread then holds a reference to freed memory. This is rare, timing-dependent, and nearly impossible to debug from crash logs.

The fix is to never share mutable state without synchronization: use an actor, a lock (like Mutex from the Synchronization framework), or keep the state on one queue. Swift 6’s strict concurrency checking turns most of these races into compile-time errors, which is one of the best reasons to adopt it.

6. Over-releasing in Objective-C and C code

Manual reference counting (MRC) Objective-C, C libraries, and old properties declared assign or unsafe_unretained can all leave dangling object pointers. Swift code that later touches such an object will crash, or worse.

Some classic examples come from older Apple APIs. Before iOS 9 and macOS 10.11, NotificationCenter didn’t hold selector-based observers weakly, so forgetting to remove an observer in dealloc meant notifications were later sent to a zombie. Modern SDKs fixed most of these, but you can still hit them in old third-party code.

When Exactly Does an Object Die?

Many zombie bugs come from a wrong assumption about when ARC destroys an object. In Swift, an object is only guaranteed to live until the last use of its last strong reference, not until the end of the scope:

func observe() {
    let observer = Observer()
    let token = Unmanaged.passUnretained(observer).toOpaque()
    register(token)
    // `observer` isn't used again, so ARC is allowed to release it right here,
    // while the registered callback can still fire.
    runLoop()
}

In practice the compiler usually keeps local variables alive until the end of their scope, but that’s an optimization detail you shouldn’t rely on, especially when an unowned, weak, or unsafe reference is the only other thing pointing at the object. When you need a guarantee, say so explicitly:

withExtendedLifetime(observer) {
    runLoop() // observer is guaranteed alive for this whole closure
}

Why Zombies Are Bad

  • Crashes and data corruption. Except for checked unowned access, use-after-free is undefined behavior. A crash is the good outcome; the bad one is a write into memory that now belongs to a different object.
  • Heisenbugs. The failure depends on timing and on what the allocator did with the freed memory, so it may not reproduce on your machine, in the debugger, or twice in a row.
  • Crash reports point in the wrong place. The crash often happens far from the bug, in code that innocently used an object someone else over-released.
  • False fixes. “Fixing” leaks by switching to unowned can trade a leak for a crash. Prefer structural fixes: clear ownership and weak references.

How to Avoid Zombies

1. Use weak and unowned Correctly

Prefer weak when the referenced object may be destroyed first. A weak reference automatically becomes nil when its object dies, so it can never point to a dead object. Delegates are the classic case:

protocol DataSourceDelegate: AnyObject {
    func didUpdate()
}

final class DataSource {
    weak var delegate: DataSourceDelegate?
}

final class ViewController: UIViewController, DataSourceDelegate {
    private let dataSource = DataSource()

    override func viewDidLoad() {
        super.viewDidLoad()
        dataSource.delegate = self
    }

    func didUpdate() { /* ... */ }
}

The AnyObject constraint on the protocol is what allows weak here: only class instances can be referenced weakly.

Reserve unowned for relationships where the referenced object is guaranteed to outlive the reference, and where that guarantee doesn’t depend on what callers do.

2. Use Capture Lists for Escaping Closures

Closures capture self strongly by default. That never creates a zombie, but a closure stored by self that captures self creates a retain cycle. Capture self weakly in escaping closures that might run after the owner is gone:

final class Worker {
    var onFinish: (() -> Void)?

    func start() {
        onFinish = { [weak self] in
            guard let self else { return } // the Worker is gone; nothing to do
            self.report()
        }
    }

    private func report() { /* ... */ }
}

Once the guard let succeeds, self is a strong reference again, so it stays alive until the closure returns.

3. Tie Async Work to Its Owner’s Lifetime

Instead of checking whether the owner still exists every time a callback fires, it’s often cleaner to cancel work when the owner goes away. With Swift concurrency:

final class Loader {
    private var task: Task<Void, Never>?

    func load(from url: URL, completion: @escaping @MainActor (Data?) -> Void) {
        task?.cancel()
        task = Task { [weak self] in
            let data = try? await URLSession.shared.data(from: url).0
            guard let self, !Task.isCancelled else { return }
            await self.store(data)
            await completion(data)
        }
    }

    private func store(_ data: Data?) async { /* ... */ }

    deinit {
        task?.cancel()
    }
}

The task doesn’t keep the Loader alive while the download runs, and when the Loader is destroyed, deinit cancels the task. If you use SwiftUI, the .task modifier does this for you: the task is cancelled automatically when the view disappears.

4. Enable Zombie Detection (When Debugging)

Product → Scheme → Edit Scheme → Run → Diagnostics → Zombie Objects.

With this on, Objective-C objects aren’t freed when they die. They become zombies that log the problem the moment anything touches them:

*** -[MyApp.Box retain]: message sent to deallocated instance 0x102e3a070

This works for Objective-C objects, including Swift classes that inherit from NSObject. Pure Swift classes don’t become zombies (their unowned references are already checked, as described above), so use Address Sanitizer for those.

Because zombie memory is never freed, memory use grows constantly. Only enable it while you’re hunting a specific bug. If you want to see the whole retain/release history of the object, run your app with the Zombies template in Instruments, which records every retain and release and shows you exactly which one was too many.

5. Use Sanitizers and Memory Tools

  • Address Sanitizer (ASan): The best general tool for use-after-free. It catches accesses to freed memory in Swift, C, and Objective-C, and shows where the memory was allocated, freed, and accessed. Enable it in Scheme → Run → Diagnostics. It slows your app down roughly 2–3×.
  • Thread Sanitizer (TSan): Finds data races, including the reference races from cause #5. It can’t be enabled together with Address Sanitizer, so run them separately.
  • Memory Graph Debugger: Click the memory graph button in Xcode’s debug bar to see what’s holding each object alive. Great for finding retain cycles and unexpected owners.
  • Instruments → Leaks and Allocations: Track memory growth, find leaked objects, and inspect allocation history.
  • Malloc Scribble: Fills freed memory with a recognizable byte pattern (0x55), so a use-after-free reads obviously wrong values instead of plausible stale ones.
  • Guard Malloc: Puts each allocation on its own memory page and makes freed pages inaccessible, so a use-after-free crashes immediately at the faulty line. It uses a lot of memory and is available in the simulator and on macOS, not on iOS devices.

6. Keep unsafe Code Small and Contained

Wrap unsafe pointers and Unmanaged in a small type with a safe API, keep pointers inside the withUnsafe... closures that created them, and document who owns each retain. Treat compiler warnings about dangling pointers as errors. Since Swift 6.2, you can also enable strict memory safety checking, which flags every use of unsafe constructs so they’re easy to find and review.

Conclusion

Swift’s ARC removes most manual memory bookkeeping, but lifetime mistakes still happen. Treat “zombies” as the debugger’s name for use-after-free. You can avoid them by modeling ownership clearly, preferring weak over unowned unless the lifetimes are truly guaranteed, cancelling async work when its owner goes away, never sharing mutable references across threads without synchronization, and keeping unsafe code small. When something does go wrong, Zombie Objects, Address Sanitizer, Thread Sanitizer, and the Memory Graph Debugger will usually point you right at it.

Happy coding!