DML, Upserts, MERGE and DDL Schema Builders¶
Construct parameterized INSERT, UPDATE, DELETE, VALUES, COPY, MERGE, upsert, and portable DDL statements with automatic schema-model binding and catalog/Arrow type inference.
INSERT, UPDATE, DELETE and RETURNING¶
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
query = sql.insert("users").columns("name").values("Ada").returning("id", "name")
result = session.execute(query)
assert result.one() == {"id": 1, "name": "Ada"}
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
session.execute("INSERT INTO users (name) VALUES ('Old')")
query = sql.update("users").set("name", "New").where_eq("id", 1)
result = session.execute(query)
assert result.rows_affected == 1
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE sessions (id INTEGER PRIMARY KEY, expired INTEGER)")
session.execute_many("INSERT INTO sessions (expired) VALUES (?)", [(0,), (1,), (1,)])
delete_expired = sql.delete("sessions").where_eq("expired", 1)
result = session.execute(delete_expired)
assert result.rows_affected == 2
Combine .values(...), .add_values(...), Insert.from_select(...), multi-table Update.from_(...) / Update.join(...), sql.values(...), sql.copy(...) (copy_from, copy_to), and .returning(...). The # SQL: comments omit identifier quoting and table qualification, as on the SELECT page:
from sqlspec import sql
src = sql.select("id", "email").from_("staging_users").where_eq("verified", True)
ins_sel = (
sql
.insert("users")
.columns("id", "email")
.from_select(src)
.returning("id", "created_at")
)
# SQL: INSERT INTO users (id, email) SELECT id, email FROM staging_users
# SQL: WHERE verified = :verified RETURNING id, created_at
# Parameters: {'verified': True}
upd_join = (
sql
.update("users")
.set("tier", "vip")
.from_("staging_users", alias="s")
.where("users.id = s.id")
)
# SQL: UPDATE users SET tier = :tier FROM staging_users AS s WHERE users.id = s.id
# Parameters: {'tier': 'vip'}
vals = sql.values([("a", 1), ("b", 2)], alias="v", columns=["code", "qty"])
# SQL: (VALUES (:code, :qty), (:code_1, :qty_1)) AS v(code, qty)
# Parameters: {'code': 'a', 'qty': 1, 'code_1': 'b', 'qty_1': 2}
copy_stmt = sql.copy(
"events", source="/tmp/events.csv", options={"format": "csv", "header": True}
)
# SQL: COPY events FROM '/tmp/events.csv' WITH (FORMAT 'csv', HEADER TRUE)
Schema Model Binding¶
Bind msgspec.Struct, pydantic.BaseModel, dataclass, attrs, or dict payloads directly via Insert.values_from(data), Insert.values_from_many(items), and Update.set_from(data). Model binding uses schema_dump(..., wire_format=False) so SQL columns always match Python attribute names. exclude_unset=True (the default) drops only fields the model reports as unset, such as msgspec UNSET values or fields missing from Pydantic's model_fields_set; msgspec defaults are still written:
import msgspec
from sqlspec import sql
class UserCreate(msgspec.Struct):
email: str
role: str = "member"
batch_ins = sql.insert("users").values_from_many([
UserCreate(email="[email protected]", role="admin"),
UserCreate(email="[email protected]"),
])
# SQL: INSERT INTO users (email, role)
# SQL: VALUES (:email, :role), (:email_1, :role_1)
# Parameters: {'email': '[email protected]', 'role': 'admin',
# Parameters: 'email_1': '[email protected]', 'role_1': 'member'}
patch_upd = (
sql
.update("users")
.set_from({"email": "[email protected]"})
.where_eq("id", 1)
)
# SQL: UPDATE users SET email = :email WHERE id = :id
# Parameters: {'email': '[email protected]', 'id': 1}
Dialect-Aware Upserts and MERGE¶
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
insert_ignore = (
sql.insert("settings").columns("key", "value").values("theme", "dark").on_conflict("key").do_nothing()
)
session.execute(insert_ignore)
upsert = (
sql
.insert("settings")
.columns("key", "value")
.values("theme", "light")
.on_conflict("key")
.do_update(value="light")
)
session.execute(upsert)
merge_built = (
sql
.merge("settings", dialect="postgres")
.using([{"key": "theme", "value": "dark"}], alias="src")
.on("settings.key = src.key")
.when_matched_then_update(value="src.value")
.when_not_matched_then_insert(key="src.key", value="src.value")
.build()
)
assert "MERGE INTO" in merge_built.sql
row = session.select_one("SELECT value FROM settings WHERE key = :key", key="theme")
assert row["value"] == "light"
| Upsert API | Target Dialects | Behavior |
|---|---|---|
.on_conflict(*cols).do_update(...) / .do_nothing() |
postgres, cockroachdb, sqlite, duckdb |
Compiles ON CONFLICT (...) DO UPDATE SET ... or DO NOTHING |
.on_duplicate_key_update(**kwargs) |
mysql, mariadb |
Compiles ON DUPLICATE KEY UPDATE col = :val |
sql.merge(table, dialect=...) |
postgres (15+), oracle, bigquery, db2, tsql |
Full MERGE INTO with WHEN MATCHED / NOT MATCHED clauses; raises DialectNotSupportedError on sqlite and mysql |
sql.upsert(table, dialect=...) |
All dialects | Returns Merge on postgres, oracle, bigquery, and db2, and Insert on every other dialect |
When Merge.using(rows, alias="src") receives a Python dict or list[dict], SQLSpec expands it into a dialect-native row source: jsonb_to_recordset(CAST(:json_data AS JSONB)) bound to the row list on PostgreSQL, JSON_TABLE(:json_payload, '$[*]' COLUMNS (...)) bound to a JSON string on Oracle, and typed SELECT CAST(:id AS BIGINT) ... FROM SYSIBM.SYSDUMMY1 rows on Db2:
from sqlspec import sql
merge_q = (
sql
.merge("products", dialect="postgres")
.using([{"id": 1, "name": "Ada"}], alias="src")
.on("products.id = src.id")
.when_matched_then_update(name="src.name")
.when_not_matched_then_insert(id="src.id", name="src.name")
.when_not_matched_by_source_then_delete()
)
# SQL: MERGE INTO products USING (SELECT id, name FROM
# SQL: JSONB_TO_RECORDSET(CAST(%(json_data)s AS JSONB))
# SQL: AS src_data(id INT, name TEXT)) AS src(id, name)
# SQL: ON (products.id = src.id) WHEN MATCHED THEN UPDATE ...
# Parameters: {'json_data': [{'id': 1, 'name': 'Ada'}]}
DDL Builders and Schema Inference¶
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
users_ddl = (
sql
.create_table("users")
.if_not_exists()
.column("id", "INTEGER", primary_key=True)
.column("email", "TEXT", not_null=True, unique=True)
)
idx_ddl = sql.create_index("idx_users_email").on_table("users").columns("email")
events_ddl = CreateTable.from_records("events", [{"id": 1, "event_type": "signup"}], primary_key="id")
session.execute(users_ddl)
session.execute(idx_ddl)
session.execute(events_ddl)
session.execute(sql.insert("events").columns("id", "event_type").values(1, "signup"))
rows = session.execute(sql.select("id", "event_type").from_("events")).all()
assert rows == [{"id": 1, "event_type": "signup"}]
| Factory Method | Builder Class | SQL Statement & Key Methods |
|---|---|---|
sql.create_table(name) |
CreateTable |
CREATE TABLE: .column(..., identity=..., generated_expression=..., persisted=True), .primary_key_constraint, .foreign_key_constraint, .unique_constraint, .check_constraint, .partition_by, .in_schema, .if_not_exists |
sql.create_table_as_select() |
CreateTableAsSelect |
CREATE TABLE ... AS SELECT: .name(n), .as_select(query), .columns(...), .if_not_exists() |
sql.create_index(name) |
CreateIndex |
CREATE INDEX: .on_table(t), .columns(...), .include(...), .unique(), .where(...), .using(...), .clustered(), .if_not_exists() |
sql.create_sequence(name) |
CreateSequence |
CREATE SEQUENCE: .start_with(n), .increment_by(n), .min_value(n), .max_value(n), .cache(n), .cycle(), .no_cycle() |
sql.create_view(name) / sql.create_materialized_view(name) |
CreateView / CreateMaterializedView |
CREATE [MATERIALIZED] VIEW: .as_select(query), .columns(...), .if_not_exists() |
sql.create_schema(name) |
CreateSchema |
CREATE SCHEMA: .if_not_exists(), .authorization(role) |
sql.alter_table(name) |
AlterTable |
ALTER TABLE: .add_column, .drop_column, .alter_column_type, .rename_column, .add_constraint, .drop_constraint, .set_not_null, .drop_not_null, .set_column_default, .drop_column_default |
sql.drop_table(name) / sql.drop_index(name) |
DropTable / DropIndex |
DROP TABLE / INDEX: .if_exists(), .cascade(), .restrict() |
sql.drop_sequence(name) / sql.drop_view(name) / sql.drop_materialized_view(name) / sql.drop_schema(name) |
DropSequence / DropView / DropMaterializedView / DropSchema |
DROP SEQUENCE / [MATERIALIZED] VIEW / SCHEMA: .if_exists(), .cascade() |
sql.rename_table(old) / sql.truncate(name) / sql.comment_on() |
RenameTable / Truncate / CommentOn |
ALTER TABLE ... RENAME TO (.to(new)), TRUNCATE (.restart_identity(), .continue_identity(), .cascade()), COMMENT ON (.on_table(t), .on_column(t, c), .is_(text)) |
create_temporal_table() in sqlspec.builder builds system-versioned tables. Synthesize DDL directly from PyArrow schemas (CreateTable.from_arrow_schema()), Python dicts or structs (CreateTable.from_records()), or DataDictionary catalog metadata (CreateTable.from_metadata(), CreateIndex.from_metadata(), and compile_table_ddl_results()).