A modern, native Microsoft SQL Server client for iOS, macOS, and Linux — written in Swift.
Built on top of the open-source FreeTDS library, SQLClient-Swift provides a clean async/await API, automatic Decodable row mapping, full TLS/encryption support for Azure SQL and SQL Server 2022, and thread safety via Swift's actor model.
This is a Swift rewrite and modernisation of martinrybak/SQLClient, bringing it up to date with FreeTDS 1.5 and modern Swift Concurrency.
async/awaitAPI — no completion handlers, no callbacks- Swift
actor— connection state is safe across concurrent callers by design Decodablerow mapping — map query results directly to your Swift structs- Typed
SQLRow— access columns asString,Int,Date,UUID,Decimal, and more SQLDataTable&SQLDataSet— typed, named tables with JSON serialisation and Markdown rendering- Full TLS support —
off,request,require, andstrict(TDS 8.0 / Azure SQL) - FreeTDS 1.5 — NTLMv2, read-only AG routing, Kerberos auth, IPv6, cluster failover
- Affected-row counts —
rowsAffectedfromINSERT/UPDATE/DELETE - Parameterised queries — built-in SQL injection protection via
?placeholders - All SQL Server date types —
date,time,datetime2,datetimeoffsetas nativeDate - Swift Package Manager — single dependency, no Ruby tooling required
| Component | Minimum version |
|---|---|
| iOS | 16.0 |
| macOS | 13.0 |
| tvOS | 16.0 |
| Xcode | 15.0 |
| Swift | 5.9 |
| FreeTDS | 1.0 (1.5 recommended) |
Add the dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/vkuttyp/SQLClient-Swift.git", from: "1.0.0")
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "SQLClientSwift", package: "SQLClient-Swift")
]
)
]Or in Xcode: File → Add Package Dependencies… and enter the repository URL.
SQLClient-Swift wraps FreeTDS — you need the native library present at build time.
macOS (Homebrew):
brew install freetdsLinux (apt):
sudo apt install freetds-deviOS / custom build: Use a pre-compiled libsybdb.a (e.g. from FreeTDS-iOS) and link it manually in your Xcode target under Build Phases → Link Binary With Libraries, along with libiconv.tbd.
import SQLClientSwift
let client = SQLClient.shared
// Connect
try await client.connect(
server: "myserver.database.windows.net",
username: "myuser",
password: "mypassword",
database: "MyDatabase"
)
// Query
let rows = try await client.query("SELECT id, name FROM Users")
for row in rows {
print(row.int("id") ?? 0, row.string("name") ?? "")
}
// Disconnect
await client.disconnect()The simplest form uses individual parameters:
try await client.connect(
server: "hostname\\instance", // or "hostname:1433"
username: "sa",
password: "secret",
database: "MyDB" // optional
)For advanced options, use SQLClientConnectionOptions:
var options = SQLClientConnectionOptions(
server: "myserver.database.windows.net",
username: "myuser",
password: "mypassword",
database: "MyDatabase"
)
options.port = 1433
options.encryption = .strict // required for Azure SQL / SQL Server 2022
options.loginTimeout = 10 // seconds
options.queryTimeout = 30 // seconds
options.readOnly = true // connect to an Availability Group read replica
try await client.connect(options: options)query() returns [SQLRow] from the first result set. Each SQLRow provides ordered, typed column access:
let rows = try await client.query("SELECT * FROM Products")
for row in rows {
let id: Int? = row.int("ProductID")
let name: String? = row.string("Name")
let price: Decimal? = row.decimal("Price")
let added: Date? = row.date("DateAdded")
let sku: UUID? = row.uuid("SKU")
let thumb: Data? = row.data("Thumbnail")
let active: Bool? = row.bool("IsActive")
if row.isNull("DiscontinuedDate") {
print("\(name ?? "") is still available")
}
}You can also access columns by zero-based index:
let firstColumn = row[0]Map rows directly to your own Decodable structs. Column name matching is case-insensitive and handles snake_case ↔ camelCase automatically:
struct Product: Decodable {
let productID: Int
let name: String
let price: Decimal
let dateAdded: Date
}
// "product_id", "ProductID", and "productId" all match the `productID` property
let products: [Product] = try await client.query(
"SELECT product_id, name, price, date_added FROM Products"
)execute() returns a SQLClientResult containing all result sets and the affected-row count. Use this when running multi-statement batches or when you need rowsAffected:
let result = try await client.execute("""
SELECT * FROM Orders WHERE Status = 'Open';
SELECT COUNT(*) AS Total FROM Orders;
""")
let openOrders = result.tables[0] // first result set
let countRow = result.tables[1].first // second result set
print("Total orders:", countRow?.int("Total") ?? 0)Use run() for data-modification statements. It returns the number of affected rows:
let affected = try await client.run(
"UPDATE Users SET LastLogin = GETDATE() WHERE UserID = 42"
)
print("\(affected) row(s) updated")Use ? placeholders to pass values safely. Strings are automatically escaped to prevent SQL injection:
// SELECT with parameters
let rows = try await client.execute(
"SELECT * FROM Users WHERE Name = ? AND Active = ?",
parameters: ["O'Brien", true]
)
// INSERT with parameters
try await client.run(
"INSERT INTO Log (UserID, Message, CreatedAt) VALUES (?, ?, ?)",
parameters: [42, "Logged in", Date()]
)
// NULL parameter
try await client.run(
"UPDATE Users SET ManagerID = ? WHERE UserID = ?",
parameters: [nil, 7]
)Note: This uses string-level escaping (single-quote doubling). For maximum security with untrusted user input, prefer stored procedures.
SQLDataTable is a typed, named result table — the Swift equivalent of .NET's DataTable. Each cell is a strongly-typed SQLCellValue enum, the table is Codable for JSON serialisation, and it can render itself as a Markdown table.
SQLDataSet is a collection of SQLDataTable instances, used when a query or stored procedure returns multiple result sets.
let table = try await client.dataTable("SELECT * FROM Users")
print(table.rowCount) // number of rows
print(table.columnCount) // number of columns// By row index and column name (case-insensitive)
let cell: SQLCellValue = table[0, "Name"]
// By row and column index
let cell: SQLCellValue = table[0, 0]
// As a typed value
switch table[0, "Age"] {
case .int32(let age): print("Age:", age)
case .null: print("Age unknown")
default: break
}
// As Any? for interop with existing code
let raw: Any? = table[0, "Name"].anyValue
// Whole row as a dictionary
let dict: [String: SQLCellValue] = table.row(at: 0)
// All values in a column
let names: [SQLCellValue] = table.column(named: "Name")print(table.toMarkdown())Output example:
| ID | Name | Email |
|---|---|---|
| 1 | Alice | alice@example.com |
| 2 | Bob | bob@example.com |
struct User: Decodable {
let id: Int
let name: String
let email: String
}
let users: [User] = try table.decode()SQLDataTable and SQLDataSet are fully Codable:
let json = try JSONEncoder().encode(table)
let restored = try JSONDecoder().decode(SQLDataTable.self, from: json)let result = try await client.execute("SELECT * FROM Orders")
// First result set as SQLDataTable
let table = result.asDataTable(name: "Orders")
// All result sets as SQLDataSet
let ds = result.asSQLDataSet()Use dataSet() when a stored procedure or batch returns more than one result set:
let ds = try await client.dataSet("EXEC sp_GetDashboard")
// Access by index
let summary = ds[0]
// Access by name (case-insensitive, uses the table name assigned by the procedure)
let details = ds["Details"]
print(ds.count) // number of tablesSQLDataTable can be converted back to [SQLRow] if you need to pass it to existing code:
let sqlRows: [SQLRow] = table.toSQLRows()All errors are thrown as SQLClientError, which conforms to LocalizedError:
do {
try await client.connect(server: "badhost", username: "sa", password: "wrong")
} catch SQLClientError.connectionFailed(let server) {
print("Could not reach \(server)")
} catch SQLClientError.alreadyConnected {
print("Already connected — call disconnect() first")
} catch {
print("Error:", error.localizedDescription)
}| Error case | When thrown |
|---|---|
.alreadyConnected |
connect() called while already connected |
.notConnected |
execute() or query() called before connecting |
.loginAllocationFailed |
FreeTDS internal allocation failure |
.connectionFailed(server:) |
TCP connection or login rejected by the server |
.databaseSelectionFailed(_) |
USE <database> command failed |
.executionFailed |
dbsqlexec() returned an error |
.noCommandText |
An empty SQL string was passed |
Informational messages from SQL Server (PRINT, low-severity RAISERROR) are delivered via NotificationCenter rather than thrown, since they are non-fatal:
NotificationCenter.default.addObserver(
forName: .SQLClientMessage,
object: nil,
queue: .main
) { notification in
let code = notification.userInfo?[SQLClientMessageKey.code] as? Int ?? 0
let message = notification.userInfo?[SQLClientMessageKey.message] as? String ?? ""
let severity = notification.userInfo?[SQLClientMessageKey.severity] as? Int ?? 0
print("Server message [\(severity)] #\(code): \(message)")
}| Mode | Description | Use when |
|---|---|---|
.off |
No TLS | On-premise, fully trusted network |
.request |
Opportunistic TLS (default) | General on-premise use |
.require |
Always encrypt, skip cert validation | Self-signed certificates |
.strict |
TDS 8.0 — always encrypt, validate cert | Azure SQL, SQL Server 2022 |
For Azure SQL Database or any server with forced encryption enabled:
var options = SQLClientConnectionOptions(
server: "yourserver.database.windows.net",
username: "myuser",
password: "mypassword"
)
options.encryption = .strict
try await client.connect(options: options)| SQL Server type | Swift type | SQLCellValue case |
|---|---|---|
tinyint |
NSNumber (UInt8) |
.int16 |
smallint |
NSNumber (Int16) |
.int16 |
int |
NSNumber (Int32) |
.int32 |
bigint |
NSNumber (Int64) |
.int64 |
bit |
NSNumber (Bool) |
.bool |
real |
NSNumber (Float) |
.float |
float |
NSNumber (Double) |
.double |
decimal, numeric |
NSDecimalNumber |
.decimal |
money, smallmoney |
NSDecimalNumber (4 dp) |
.decimal |
char, varchar, nchar, nvarchar |
String |
.string |
text, ntext, xml |
String |
.string |
binary, varbinary, image |
Data |
.bytes |
timestamp |
Data |
.bytes |
datetime, smalldatetime |
Date |
.date |
date, time, datetime2, datetimeoffset |
Date |
.date |
uniqueidentifier |
UUID |
.uuid |
null |
NSNull |
.null |
sql_variant, cursor, table |
— |
Date types note:
date,time,datetime2, anddatetimeoffsetare returned asDatewhen using TDS 7.3 or higher. FreeTDS 1.x defaults toautoprotocol negotiation, which will select 7.3+ automatically for modern SQL Server versions. If you see strings instead of dates on an older server, set theTDSVERenvironment variable in your Xcode scheme to7.3orauto.
Controls the maximum bytes returned for TEXT, NTEXT, and VARCHAR(MAX) columns. Default is 4096 bytes.
// In your setup code, before connecting:
SQLClient.shared.maxTextSize = 65536Set the TDSVER environment variable in your Xcode scheme (Edit Scheme → Run → Arguments → Environment Variables):
| Value | Protocol | Compatible with |
|---|---|---|
auto |
Autodetect (recommended) | All SQL Server versions |
7.4 |
TDS 7.4 | SQL Server 2012+ |
7.3 |
TDS 7.3 | SQL Server 2008 |
7.2 |
TDS 7.2 | SQL Server 2005 |
7.1 |
TDS 7.1 | SQL Server 2000 |
- Stored procedure OUTPUT parameters are not yet supported. Stored procedures that return result sets via
SELECTwork normally. - Connection pooling is not built in. For high-concurrency server-side apps, create multiple
SQLClientinstances manually. - Single-space strings: FreeTDS may return
""instead of" "in some server configurations (upstream FreeTDS bug). sql_variant,cursor, andtableSQL Server types are not supported.
- FreeTDS — freetds.org · FreeTDS/freetds
- Original Objective-C library — martinrybak/SQLClient by Martin Rybak
- FreeTDS iOS binaries — patchhf/FreeTDS-iOS
SQLClient-Swift is released under the MIT License. See LICENSE for details.
FreeTDS is licensed under the GNU LGPL. See the FreeTDS license for details.