Objective-C

KVO’s Hidden Subclass: How isa-Swizzling Changes Objective-C Objects at Runtime

By Seren  |  29 Aug, 2026  |  Leave a comment


I first noticed KVO’s hidden subclass while debugging a setter breakpoint. The object printed as Person, but the call stack showed a class name I had never created. For a few minutes I wondered whether another library had swizzled the model behind my back.

Printing both [object class] and object_getClass(object) before and after adding the observer made the change obvious. The public class stayed the same, while the runtime class changed. That small experiment finally made KVO feel mechanical instead of magical.

Below is the path I followed, including the places where this implementation detail actually matters and the places where application code should deliberately ignore it.

The Two Log Lines That Gave It Away

Before observation, an object’s runtime class is normally the class used to create it. After an observer is registered for an automatically observable key, the runtime may create an intermediate subclass and redirect the instance’s isa pointer to it. Other instances of the original class do not need to change.

Apple describes this technique as isa-swizzling. The generated subclass overrides the observed setter, and it also arranges for ordinary class queries to preserve the expected abstraction. That is why [object class] can continue to report the original class while object_getClass(object) reveals a different runtime class.

Person *person = [Person new];
NSLog(@"public: %@", [person class]);
NSLog(@"runtime: %@", object_getClass(person));

[person addObserver:observer
          forKeyPath:@"name"
             options:NSKeyValueObservingOptionNew
             context:&Context];

NSLog(@"public: %@", [person class]);
NSLog(@"runtime: %@", object_getClass(person));

Run this in a debug target and compare the two logs before and after registration. Treat the runtime subclass name as diagnostic evidence only. Code should not branch on its exact spelling.

Following the Setter Call

The generated setter conceptually performs three steps: announce that a value will change, invoke the original implementation, and announce that the change completed. The KVO machinery records the old value when requested, reads the new value, and delivers an observation change to registered observers.

A simplified mental model looks like this:

- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    [super setName:name];
    [self didChangeValueForKey:@"name"];
}

The actual implementation is runtime-managed and more careful, but this model explains why automatic observation follows setter calls. Directly changing an instance variable can bypass the setter and therefore bypass automatic notification. Manual notification is possible, but it must match the documented KVO rules.

This also explains dependent behavior during debugging. A breakpoint on the original setter may appear to arrive through an unfamiliar subclass. The receiver is still the same object; its dispatch path has changed.

Why My Two Class Checks Disagreed

-class is an Objective-C method and can be overridden. The KVO-generated subclass can return its superclass so application code continues to see the public type. object_getClass reads the object’s actual runtime class pointer and is therefore useful for low-level inspection.

Apple specifically advises developers not to rely on the isa pointer for class membership. Use normal APIs such as isKindOfClass: and class for application logic. Runtime inspection belongs in diagnostics, tests, and tooling where the implementation distinction is relevant.

This separation protects encapsulation. Frameworks are free to insert dynamic subclasses for KVO or other behavior without breaking code that uses the object according to its public contract.

The Awkward Part: Mixing KVO with Swizzling

Method swizzling changes which implementation corresponds to a selector on a class. KVO may place its override on a dynamic subclass. If swizzling code assumes every instance dispatches directly through the original class, the order of registration and swizzling can produce surprising results.

For example, swizzling the original setter after an object has begun observation may not replace the override on the generated subclass. Swizzling the hidden subclass is worse because it depends on private implementation and may affect only a subset of instances. The safer design is to avoid combining broad setter swizzling with KVO when a documented composition point is available.

If interception is unavoidable, test both orders: install interception before adding the observer, and add the observer before installing interception. Test observed and unobserved instances. Verify that each assignment calls the original setter once and delivers the expected number of notifications. Do not infer correctness from a single happy-path log.

When I Turn Off Automatic Notifications

A class can opt out of automatic notifications for a key by implementing +automaticallyNotifiesObserversForKey:. Manual notification is appropriate when several internal changes form one logical update, or when mutation does not pass through an ordinary setter.

Manual control carries responsibility. Calls to willChangeValueForKey: and didChangeValueForKey: must be balanced, and nested or collection changes require the correct variants. Sending both automatic and manual notifications for the same mutation can produce duplicates.

Document why a key is manual and test its change dictionaries. Observers may depend on old and new values, so a notification that merely “fires” is not necessarily correct.

Observation Lifecycle and Removal

Traditional Objective-C KVO APIs require careful lifecycle management. The observer, observed object, and registration form a relationship that must end predictably. Modern token-based or Swift observation APIs can simplify ownership, but older code often still uses context pointers and explicit removal.

Use a unique static context address rather than comparing string key paths in a shared callback. Forward unknown contexts to super. Keep registration and removal close to the owner of the observation, and avoid broad exception handling that hides double-removal mistakes.

Reproduce lifecycle cases in tests: observer deallocates first, observed object deallocates first, observation is replaced, and callbacks trigger further mutations. A passing setter test does not cover teardown behavior.

My Debugging Order Now

When a notification is missing, first confirm that the observed key is KVC-compliant and that mutation goes through the expected setter. Log [object class] and object_getClass(object) before and after registration. Then place a symbolic breakpoint on the setter and inspect the receiver’s runtime class.

If notifications are duplicated, search for manual willChange/didChange calls and verify whether automatic notifications are still enabled. Also inspect repeated registrations. If the problem appears only after swizzling, reduce the test to one class, one key, and one instance, then vary installation order.

Use these observations to test a hypothesis, not to couple product logic to private subclasses. Runtime internals are excellent debugging clues and poor long-term application contracts.

Notes I Keep for Older KVO Code

  • Use public class-membership APIs in application logic.
  • Use object_getClass only when runtime identity matters for diagnostics.
  • Ensure observed mutations pass through a compliant setter or use balanced manual notifications.
  • Do not depend on the hidden subclass’s exact name.
  • Test interactions with swizzling in both installation orders.
  • Give every traditional registration a clear lifecycle owner.
  • Use unique context pointers and forward unknown contexts.
  • Test old/new values, duplicate delivery, and teardown—not only callback presence.

After this experiment, KVO stopped being a piece of “runtime magic” in my head. I now picture one object, a changed dispatch path, and a setter wrapped with notification work. The observed object does not become a different model object. Its runtime class temporarily participates in an extra layer that wraps selected setters and preserves the public class abstraction.

Apple Documentation Behind the Experiment

Apple’s archived KVO Implementation Details documents isa-swizzling and warns against using isa for class membership. The Objective-C runtime documentation describes runtime class inspection. Because the generated subclass is an implementation detail, verify observations on the OS versions supported by your app and avoid shipping code that assumes private names or layouts.

Download the Complete Runnable Project

The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/objc-kvo-isa-swizzling-demo.

Download it with git clone, then follow the repository README to build and run the example locally.

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *