Swift Macros: How They Work, How to Read Them, and How to Write Your Own


Every time you type @Model on a SwiftData class or @Observable on a view model, something unusual happens: the compiler runs a Swift program that reads your source code as a syntax tree and writes new source code back into your file — before a single line of your app compiles. That’s not a property wrapper. That’s a macro.

This post covers everything you need to go from macro consumer to macro author: how the macro system works, how to expand macros in Xcode to see what they actually generate, and how to write and test your own macros using SwiftSyntax. Macro testing infrastructure and the @attached role system are covered in full.

Contents

The Problem Macros Solve

Before macros, eliminating boilerplate meant choosing between property wrappers (limited to a fixed transformation of a stored property), code generation tools (external, not integrated with the compiler), or just typing the same thing over and over. Consider a studio pipeline model where every entity needs Equatable conformance for diffing:

struct PixarFilm {
    let title: String
    let releaseYear: Int
    let director: String
    let boxOfficeMillions: Double
}

// Manually synthesised — Swift does this for free on structs.
// But for a class with stored properties + computed properties
// + properties you want to EXCLUDE from equality, you write this:
extension PixarFilm: Equatable {
    static func == (lhs: PixarFilm, rhs: PixarFilm) -> Bool {
        lhs.title == rhs.title &&
        lhs.releaseYear == rhs.releaseYear &&
        lhs.director == rhs.director &&
        lhs.boxOfficeMillions == rhs.boxOfficeMillions
    }
}

For a struct with compiler-synthesised Equatable, this is free. But the moment you add a class, an @unchecked Sendable payload, or a property that should be excluded from equality (like a cachedPosterURL), you’re back to typing == by hand for every type. Across a production codebase with 40 model types, that’s hundreds of lines of code that provide zero signal — pure noise.

Macros solve this by generating the boilerplate at compile time, from a declaration you write once.

// With a macro, the equality implementation is generated for you
@AutoEquatable
class PixarFilmStore {
    var films: [PixarFilm]
    var lastFetched: Date
    @ExcludeFromEquality var cachedPosterURLs: [String: URL]
}

The @AutoEquatable macro expands to the full Equatable implementation before the compiler sees your type. You see clean code; the compiler sees complete code.

Types of Macros: Freestanding vs Attached

Swift macros come in two flavours, distinguished by their syntax.

Freestanding macros use the # sigil. They stand alone as expressions or declarations — they’re not attached to any specific type or member.

// #Preview is a freestanding declaration macro
#Preview("Pixar Film Card") {
    FilmCardView(film: .init(title: "Up", releaseYear: 2009))
}

// #require is a freestanding expression macro (Swift Testing)
let film = try #require(await filmStore.film(id: "up"))

// A custom freestanding expression macro
let description = #stringify(2 + 3)
// Expands to: ("2 + 3", 5)

Attached macros use the @ sigil and are attached to a declaration — a type, property, function, or extension.

// @Model is an attached macro (SwiftData) — iOS 17+
@available(iOS 17.0, macOS 14.0, *)
@Model
class PixarFilm {
    var title: String
    var releaseYear: Int
    init(title: String, releaseYear: Int) {
        self.title = title
        self.releaseYear = releaseYear
    }
}

// @Observable is an attached macro (Observation framework) — iOS 17+
@available(iOS 17.0, macOS 14.0, *)
@Observable
class PixarFilmStore {
    var films: [PixarFilm] = []
    var isLoading: Bool = false
}

The key difference: freestanding macros produce standalone expressions or declarations; attached macros augment an existing declaration by adding members, conformances, or wrapping property accessors.

Expanding Macros in Xcode

Before writing your own macros, get comfortable reading what existing macros generate. Xcode makes this easy.

  1. Open any file using @Observable or @Model.
  2. Right-click on the macro attribute (e.g., @Observable).
  3. Select Expand Macro from the context menu.

Xcode will inline the generated code as a read-only expansion. Here’s what @Observable generates on a PixarFilmStore class:

// Your source:
@Observable
class PixarFilmStore {
    var films: [PixarFilm] = []
    var isLoading: Bool = false
}

// After "Expand Macro", Xcode shows the generated code:
class PixarFilmStore {
    @ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()

    internal nonisolated func access<Member>(
        keyPath: KeyPath<PixarFilmStore, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, MutationResult>(
        keyPath: KeyPath<PixarFilmStore, Member>,
        _ mutation: () throws -> MutationResult
    ) rethrows -> MutationResult {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }

    @ObservationIgnored private var _films: [PixarFilm] = []
    var films: [PixarFilm] {
        get {
            access(keyPath: \.films)
            return _films
        }
        set {
            withMutation(keyPath: \.films) { _films = newValue }
        }
    }

    @ObservationIgnored private var _isLoading: Bool = false
    var isLoading: Bool {
        get {
            access(keyPath: \.isLoading)
            return _isLoading
        }
        set {
            withMutation(keyPath: \.isLoading) { _isLoading = newValue }
        }
    }
}

extension PixarFilmStore: Observable {}

This reveals exactly what @Observable is doing: it rewrites every stored property into a computed property backed by a private underscore-prefixed stored property, and calls into ObservationRegistrar on every access and mutation. That’s how SwiftUI knows to re-render when a property changes.

Under the Hood: SwiftSyntax and the Compiler Plugin Architecture

Macros are implemented as compiler plugins — separate Swift executables that the compiler forks at build time. When the compiler encounters a macro application, it:

  1. Serialises the relevant portion of the AST (Abstract Syntax Tree) to a binary format.
  2. Sends it to the plugin process via IPC.
  3. The plugin uses SwiftSyntax to parse and transform the AST.
  4. The plugin sends the generated source back to the compiler.
  5. The compiler inserts the generated code and continues type-checking.

This architecture has important implications:

  • Macros run in a sandbox. They cannot read from disk, make network calls, or access environment variables. This is intentional — macros must be deterministic and side-effect-free.
  • Macros are type-safe inputs. The input is a fully-parsed syntax tree, not raw text. You can’t accidentally produce malformed Swift — SwiftSyntax will catch structural errors.
  • Build performance. Macro expansion happens in a separate process. Heavily-used macros can add perceptible build time, especially in clean builds. This is rarely a problem in practice but worth knowing.

The system was introduced in SE-0382 (expression macros), SE-0389 (attached macros), and demonstrated at WWDC 2023, Session 10166: Write Swift macros.

Note: Macros require Swift 5.9 / Xcode 15+ and are available on all Apple platforms. They do not have an iOS version minimum — the macro runs at compile time on your Mac, not at runtime on the device.

Building a Freestanding Macro: #stringify

The canonical first macro is #stringify, which takes an expression and returns a tuple of the source code string and the evaluated result. It’s included in Xcode’s macro template for good reason: it’s the simplest possible complete macro.

Create a new Swift Package with the macro template (File → New → Package → Swift Macro). You’ll get three targets:

  • PixarMacros — the public declaration
  • PixarMacrosImpl — the compiler plugin (runs at build time)
  • PixarMacrosClient — a test client

In PixarMacros/Sources/PixarMacros/PixarMacros.swift, declare the macro:

// The public API — this is all consuming code sees
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
    #externalMacro(module: "PixarMacrosImpl", type: "StringifyMacro")

In PixarMacrosImpl/Sources/PixarMacrosImpl/StringifyMacro.swift, implement it:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        // node.arguments contains the arguments passed to the macro
        guard let argument = node.arguments.first?.expression else {
            throw MacroError.missingArgument
        }

        // Build the tuple expression: (originalExpression, "sourceText")
        // argument.description gives us the source text of the expression
        return "(\(argument), \(literal: argument.description))"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case missingArgument

    var description: String {
        switch self {
        case .missingArgument: return "#stringify requires an argument"
        }
    }
}

@main
struct PixarMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [StringifyMacro.self]
}

Now the macro works in client code:

import PixarMacros

let (result, source) = #stringify(1 + 2 + 3)
print(result)  // 6
print(source)  // "1 + 2 + 3"

let (filmCount, expression) = #stringify(films.filter { $0.releaseYear > 2000 }.count)
// filmCount: Int (the evaluated count)
// expression: "films.filter { $0.releaseYear > 2000 }.count"

The macro expands to (1 + 2 + 3, "1 + 2 + 3") — a tuple that carries both the value and its source representation. Useful for assertion messages, logging, and debugging.

Building an Attached Macro: @AutoEquatable

An attached macro that synthesises Equatable conformance demonstrates the @attached(extension) and @attached(member) roles. This macro inspects the stored properties of a type and generates a == implementation that compares them all.

Declare the macro:

// The macro adds an Equatable conformance and the == implementation
@attached(extension, conformances: Equatable)
@attached(member, names: named(==))
public macro AutoEquatable() =
    #externalMacro(module: "PixarMacrosImpl", type: "AutoEquatableMacro")

Implement it in the plugin:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct AutoEquatableMacro: MemberMacro, ExtensionMacro {

    // MARK: - MemberMacro: generates the == function

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        // Collect stored property names, excluding @ExcludeFromEquality
        let storedProperties = declaration.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .filter { variable in
                // Exclude computed properties (they have no accessor block)
                variable.bindings.contains { binding in
                    binding.accessorBlock == nil
                }
            }
            .filter { variable in
                // Exclude properties marked @ExcludeFromEquality
                !variable.attributes.contains { attr in
                    attr.as(AttributeSyntax.self)?.attributeName
                        .as(IdentifierTypeSyntax.self)?.name.text == "ExcludeFromEquality"
                }
            }
            .compactMap { variable -> String? in
                variable.bindings.first?.pattern
                    .as(IdentifierPatternSyntax.self)?.identifier.text
            }

        // Build the comparison expression: lhs.prop1 == rhs.prop1 && ...
        let comparisons = storedProperties
            .map { "lhs.\($0) == rhs.\($0)" }
            .joined(separator: " &&\n        ")

        let equalsFunctionSource: DeclSyntax = """
            public static func == (lhs: Self, rhs: Self) -> Bool {
                \(raw: comparisons)
            }
            """

        return [equalsFunctionSource]
    }

    // MARK: - ExtensionMacro: adds Equatable conformance

    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        let equatableExtension: DeclSyntax =
            "extension \(type.trimmed): Equatable {}"
        guard let extensionDecl = equatableExtension.as(ExtensionDeclSyntax.self) else {
            return []
        }
        return [extensionDecl]
    }
}

Usage in production code:

@AutoEquatable
class PixarDirector {
    var name: String
    var filmsDirected: [String]
    var awardsCount: Int
    @ExcludeFromEquality var cachedHeadshotURL: URL?

    init(name: String, filmsDirected: [String], awardsCount: Int) {
        self.name = name
        self.filmsDirected = filmsDirected
        self.awardsCount = awardsCount
    }
}

let peteDocter = PixarDirector(
    name: "Pete Docter",
    filmsDirected: ["Up", "Soul", "Inside Out"],
    awardsCount: 4
)
let samePete = PixarDirector(
    name: "Pete Docter",
    filmsDirected: ["Up", "Soul", "Inside Out"],
    awardsCount: 4
)
print(peteDocter == samePete) // true — cachedHeadshotURL is excluded

Macro Roles: The Full Taxonomy

Swift 5.9 ships with five attached macro roles and two freestanding roles:

RoleSyntaxWhat it generates
@freestanding(expression)#macro(...)An expression that produces a value
@freestanding(declaration)#macro(...)One or more declarations (func, struct, etc.)
@attached(member)@Macro on a typeNew members inside the type’s body
@attached(memberAttribute)@Macro on a typeAttributes added to existing members
@attached(extension)@Macro on a typeA new extension on the type
@attached(peer)@Macro on a memberNew declarations alongside the annotated member
@attached(accessor)@Macro on a propertyget/set/willSet/didSet accessors

@Observable uses @attached(member) to inject the _$observationRegistrar and access/withMutation methods, @attached(memberAttribute) to add @ObservationIgnored to the backing stored properties, and @attached(extension) to add the Observable conformance.

A single macro declaration can combine multiple roles:

@attached(member, names: named(_$observationRegistrar), named(access), named(withMutation))
@attached(memberAttribute)
@attached(extension, conformances: Observable)
public macro Observable() =
    #externalMacro(module: "Observation", type: "ObservationMacro")

Advanced Usage: Peer Macros and Accessor Macros

Peer macros generate new declarations alongside the annotated member, in the same scope. This is useful for generating mock or preview variants automatically:

@attached(peer, names: suffixed(Mock))
public macro GenerateMock() =
    #externalMacro(module: "PixarMacrosImpl", type: "GenerateMockMacro")

// Usage:
protocol FilmRepository {
    @GenerateMock
    func fetchFilms() async throws -> [PixarFilm]
}

// The macro generates, alongside the protocol requirement:
// func fetchFilmsMock() async throws -> [PixarFilm] { return [] }

Accessor macros wrap a stored property’s get/set. This is how @Observable rewrites stored properties — the macro replaces the simple stored property with a computed one backed by a private stored property:

@attached(accessor, names: named(init), named(get), named(set))
public macro Tracked() =
    #externalMacro(module: "PixarMacrosImpl", type: "TrackedMacro")

// Usage:
class SceneGraph {
    @Tracked var characterCount: Int = 0
}

// Expands to:
class SceneGraph {
    @ObservationIgnored private var _characterCount: Int = 0
    var characterCount: Int {
        get { access(keyPath: \.characterCount); return _characterCount }
        set { withMutation(keyPath: \.characterCount) { _characterCount = newValue } }
    }
}

Warning: Accessor macros and property wrappers are mutually exclusive on the same property. Applying both produces a compile error. If you’re migrating from property wrappers to macros, remove the wrapper before adding the accessor macro.

Testing Macros with Swift Testing and SwiftMacroTesting

Macros must be tested against their expansion output. The open-source swift-macro-testing package (by Point-Free) provides an assertMacro function that snapshots the expanded output and fails if it changes unexpectedly.

Add it to your macro package:

// Package.swift
.testTarget(
    name: "PixarMacrosTests",
    dependencies: [
        "PixarMacrosImpl",
        .product(name: "MacroTesting", package: "swift-macro-testing"),
    ]
)

Write expansion tests:

import MacroTesting
import XCTest
@testable import PixarMacrosImpl

final class AutoEquatableMacroTests: XCTestCase {

    override func invokeTest() {
        // Record mode: set to true once to capture the snapshot, then false
        withMacroTesting(
            isRecording: false,
            macros: ["AutoEquatable": AutoEquatableMacro.self]
        ) {
            super.invokeTest()
        }
    }

    func testBasicExpansion() {
        assertMacro {
            """
            @AutoEquatable
            class PixarFilm {
                var title: String
                var releaseYear: Int
            }
            """
        } expansion: {
            """
            class PixarFilm {
                var title: String
                var releaseYear: Int

                public static func == (lhs: Self, rhs: Self) -> Bool {
                    lhs.title == rhs.title &&
                    lhs.releaseYear == rhs.releaseYear
                }
            }

            extension PixarFilm: Equatable {}
            """
        }
    }

    func testExcludedPropertyIsSkipped() {
        assertMacro {
            """
            @AutoEquatable
            class PixarFilm {
                var title: String
                @ExcludeFromEquality var cachedURL: URL?
            }
            """
        } expansion: {
            """
            class PixarFilm {
                var title: String
                @ExcludeFromEquality var cachedURL: URL?

                public static func == (lhs: Self, rhs: Self) -> Bool {
                    lhs.title == rhs.title
                }
            }

            extension PixarFilm: Equatable {}
            """
        }
    }
}

In “record” mode (isRecording: true), assertMacro writes the expansion to the test file as a snapshot. Switch to false for subsequent runs — the test will fail if the macro output changes. This is the gold standard for macro regression testing.

Performance Considerations

Macro expansion happens at compile time, so there’s no runtime cost for the expansion itself. The generated code, however, is subject to normal performance rules:

  • Generated Equatable conformances perform O(n) comparisons — fine for most models, but be aware when using == in tight loops over large arrays.
  • @Observable’s generated accessors call into ObservationRegistrar on every property access from a SwiftUI tracking context. If you’re reading a property in a tight loop outside of a SwiftUI body, consider caching the value locally.
  • Compiler plugin invocations add build time. A package with 20 macro-heavy types may add 1–3 seconds to a clean build. This is generally acceptable and improves incrementally (only changed files trigger re-expansion).

Apple Docs: Macros — Swift Standard Library

When to Use (and When Not To)

ScenarioRecommendation
Generating boilerplate conformances (Equatable, Hashable, Codable)Good macro candidate — the pattern is mechanical and repetitive
Adding observation/tracking to stored propertiesUse @Observable (already solved); write a macro if you need a custom tracking system
Code that varies based on runtime valuesNot a macro — macros are compile-time only
Generating #Preview declarations for every component#Preview is already a macro; use it
Replacing property wrappers with cleaner syntaxGood candidate for complex wrappers; simple wrappers may not justify the complexity
A 3-line computed propertyAvoid — the macro infrastructure is heavier than the boilerplate you’re eliminating
Generating test doubles / mocks from protocolsExcellent candidate for @attached(peer)
Anything requiring network access or file I/O during expansionNot possible — macros run in a sandbox with no I/O
Team unfamiliar with SwiftSyntaxDefer — macros require SwiftSyntax knowledge; a property wrapper may be simpler for now

Summary

  • Macros are compiler plugins — they run at build time, receive your source as an AST via SwiftSyntax, and write new Swift code back into the compilation unit.
  • Freestanding macros (#name) produce expressions or declarations; attached macros (@Name) augment existing declarations with new members, conformances, or property accessors.
  • Expand macros in Xcode by right-clicking the attribute and selecting Expand Macro — this is the fastest way to understand what any macro is generating.
  • The @attached role system gives precise control: member adds declarations inside a type, extension adds conformances, peer generates sibling declarations, accessor rewrites property access.
  • Test macros by snapshot-testing their expansion output with swift-macro-testing — this catches regressions when SwiftSyntax versions change.

Macros are the foundation that @Observable and @Model are built on. For a deep dive into how @Observable integrates with SwiftUI’s rendering pipeline and what it replaced, see The Observation Framework.