Repository navigation
First stable release of DuckDB for Swift.
It brings DuckDB 2.0.0 directly into iOS and macOS apps with typed SQL, Codable models, async/await, reactive queries, migrations, transactions, reusable prepared statements, bulk ingestion, and connection pooling.
This is not another object store around SQLite. Duck gives your app a real analytical SQL engine and lets Swift stay Swift.
Install
Add Duck from Xcode with File -> Add Package Dependencies:
https://github.com/SwiftStream/Duck
Use Up to Next Major Version starting from 1.0.0 and add the Duck product to your target.
For a Swift package:
.package(
url: "https://github.com/SwiftStream/Duck",
from: "1.0.0"
)Then:
import DuckDuck re-exports Swift Stream SQL, so normal Duck code does not need a separate import SQL.
Start with ordinary Swift models
A database model is just a Swift type:
struct Todo: Table {
@Column("id") var id: UUID
@Column("name") var name: String
@Column("created_at") var createdAt: Date
@Column("done_at") var doneAt: Date?
@Column("deleted_at") var deletedAt: Date?
init() {}
init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
doneAt: Date? = nil,
deletedAt: Date? = nil
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.doneAt = doneAt
self.deletedAt = deletedAt
}
}Insert it directly:
let todo = Todo(name: "Try Duck")
try await todo.insertNonReturning(on: .app)and query it back as the same model:
let todos = try await Todo
.query
.where(\Todo.$deletedAt == nil)
.all(on: .app)Duck also decodes selected columns, aggregates, and computed expressions into any matching Decodable projection. Your query result does not have to map one-to-one to a table model.
Real SQL, written like Swift
Duck uses Swift Stream SQL as its query DSL.
Use fluent composition when the query is compact:
let query = Todo
.select
.where(Todo.$deletedAt == nil)
.orderBy(.asc(Todo.$createdAt))or the result-builder form when structure and Swift control flow make the query easier to read:
let query = SQL {
Select {
\Todo.$id
\Todo.$name
\Todo.$createdAt
}
From(Todo.table)
Where {
\Todo.$deletedAt == nil
}
OrderBy(.asc(\Todo.$createdAt))
}Both forms use the same preparation and binding model.
When you really need raw SQL structure, SQL.raw(...) remains available as an explicit escape hatch.
Reactive queries for SwiftUI and UIKit
QueryObservation turns a query into an AsyncSequence of its latest successful state.
In SwiftUI:
@State private var todos: [Todo] = []
.task {
for try await result in try Todo
.select
.where(Todo.$deletedAt == nil)
.orderBy(.asc(Todo.$createdAt))
.observe(on: .app)
{
todos = try result.all(decoding: Todo.self)
}
}Mutate the same logical database and the UI can react without maintaining a second persistence model or introducing a framework-specific database publisher.
The same observation API works with UIKit from an ordinary Task.
Use a database identity until you need a connection
Most application code can work directly through a DatabaseIdentifier:
let todos = try await Todo.all(on: .app)Open an explicit Connection when several operations should intentionally share one native DuckDB connection or when statement, transaction, or Appender lifetime matters:
let connection = try await DatabaseIdentifier.app.connect()
let todos = try await Todo.all(on: connection)Each Connection is an actor that owns one native DuckDB connection and serializes work submitted to it. Separate connections remain independent.
Transactions that make ownership explicit
Duck supports managed transactions:
try await connection.transaction { transaction in
// Work through transaction
}and flat transactions with explicit finalization when you need to control the lifetime yourself.
Transaction work uses the supplied DuckTransaction capability. Ordinary work on the same Connection does not silently become part of an active transaction.
Prepare once, execute many times
Reusable prepared statements keep one prepared-statement owner on their originating connection while values change between executions.
That makes them a natural fit for repeated INSERT, UPDATE, or SELECT workloads without rebuilding the query shape every time.
Statements also participate explicitly in transactions while preserving their connection ownership.
Bulk ingestion with Appender
For high-volume complete-row ingestion, Duck exposes DuckDB's Appender through a scoped Swift API.
Use a normal connection Appender when you want efficient row ingestion:
try await connection.appender(to: events) { appender in
for event in events {
try await appender.append(
event.id,
event.kind,
event.payload
)
}
}When all appended rows must share one commit or rollback boundary with other work, use the transaction-owned Appender instead.
Migrations and connection pooling
Duck includes ordered migrations for explicit schema evolution:
_ = try await DatabaseIdentifier.app
.migrator([Todo.Create()])
.migrate()and a bounded ConnectionPool for workloads that need multiple independently owned native connections.
Pool transactions use the same managed transaction model as direct connections.
Codable goes beyond simple scalars
Duck supports the expected native scalar families together with:
- exact temporal values;
Foundation.Decimal;Int128andUInt128;- native one-dimensional
LIST; - immediate keyed native
STRUCT.
Direct Swift Int128 / UInt128 bridges require macOS 15 / iOS 18 availability on Apple platforms.
LIST and STRUCT support is deliberately bounded in 1.0.0. Nested LIST, nested STRUCT, LIST inside STRUCT, STRUCT inside LIST, fixed ARRAY, MAP, UNION, and other recursive composite families are not part of this release.
Duck does not silently fall back to JSON for unsupported Codable shapes.
DuckDB 2.0.0 inside your app
Duck 1.0.0 embeds DuckDB 2.0.0.
That means the same class of engine features that make DuckDB powerful for analytical and relational workloads are available directly inside a Swift application: filtering, joins, grouping, ordering, aggregates, prepared statements, transactions, and bulk ingestion without requiring a database server.
Duck's version and DuckDB's engine version are independent.
New databases use DuckDB 2.x storage semantics by default. Existing older-format DuckDB files remain supported according to DuckDB's compatibility behavior, while files written in the DuckDB 2.x storage format cannot be opened by pre-2.0 DuckDB runtimes.
Native Apple distribution
On macOS and iOS, Duck uses a precompiled Cduckdb XCFramework, so a normal application build does not need to compile the bundled DuckDB C++ engine.
The binary includes:
- macOS ARM64 and x86_64;
- iOS ARM64;
- iOS Simulator ARM64 and x86_64.
The package declares iOS 15+ and macOS 12+ deployment targets.
The published 1.0.0 package has been exercised through the real public Swift Package Manager URL in both Xcode and a standalone SwiftPM consumer.
Platform scope
At the moment it is made for iOS and macOS.
It also can be used on Linux, Windows, and Android, but these platforms haven't been tested yet, wait for it in future releases.
Try it
Two complete iOS examples are included:
Both use the same Duck-backed Todo flow so you can see how observation, models, migrations, and persistence fit into a real app.
For the complete API guide and exact behavioral boundaries:
Duck 1.0.0 is the foundation. There is a lot more of DuckDB to bring into Swift from here. 🦆