Insert or update

On this page

The same on CockroachDB. CockroachDB runs this example unchanged: the SQL and the Go that sgl generates are identical to the PostgreSQL version. Only dialect: in sgl.yaml changes, and the program migrates with migrate_backend_cockroachdb.New().

An upsert writes a row whether or not it already exists: it inserts the row if it is new and updates the one that is there if it is not. Doing that in one statement avoids the race in "look first, then insert or update", where two callers can both see no row and both try to insert it. Every engine spells the upsert its own way, and Seagull reads each one in the engine's own syntax.

The example keeps a chip shop's stock. Each delivery adds to an item's quantity, creating the item the first time it arrives, and a second query adds an item only if it is not stocked yet.

The schema

item is the primary key. That uniqueness is what the upsert reacts to: a second row for the same item would clash with it.

db/migrations/0001_stock.up.sql

CREATE TABLE stock (
    item       TEXT   PRIMARY KEY,
    quantity   BIGINT NOT NULL,
    deliveries BIGINT NOT NULL DEFAULT 0
);

Insert or update

ON CONFLICT (item) names the conflict target, the unique column whose clash turns the insert into an update. Inside DO UPDATE, excluded is the row the INSERT tried to write, so excluded.quantity is the size of this delivery and stock.quantity is what was already there. RETURNING hands back the row as it ended up, whichever way the statement went, so the command is one.

db/queries/stock.sql

-- sgl.query(ReceiveDelivery, one)
INSERT INTO stock (item, quantity, deliveries) VALUES ($1, $2, 1)
ON CONFLICT (item) DO UPDATE SET
    quantity   = stock.quantity + EXCLUDED.quantity,
    deliveries = stock.deliveries + 1
RETURNING item, quantity, deliveries;

The generated method takes the two values the INSERT binds and returns the stored row. excluded needs no parameters of its own, because it refers to values the INSERT already has.

internal/store

type ReceiveDeliveryParams struct {
	Item     string
	Quantity int64
}

type ReceiveDeliveryRow struct {
	Item       string `json:"item"`
	Quantity   int64  `json:"quantity"`
	Deliveries int64  `json:"deliveries"`
}

func (queries *Queries) ReceiveDelivery(ctx context.Context, params ReceiveDeliveryParams) (ReceiveDeliveryRow, error) {
	var row ReceiveDeliveryRow
	err := queries.writer.QueryRowContext(ctx, receiveDeliverySQL, params.Item, params.Quantity).Scan(&row.Item, &row.Quantity, &row.Deliveries)
	if err != nil {
		return ReceiveDeliveryRow{}, err
	}
	return row, nil
}

Insert only if new

AddItem uses DO NOTHING: when the item exists, the insert is skipped and nothing changes. Declared as execrows, the method returns 1 when the row went in and 0 when it was skipped, which is all the caller needs to know.

db/queries/stock.sql

-- sgl.query(AddItem, execrows)
INSERT INTO stock (item, quantity) VALUES ($1, 0)
ON CONFLICT (item) DO NOTHING;

Using it

The program books three deliveries, two of them cod, then tries to add peas and cod as new items.

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/postgres/021_upserts/internal/store"
)

// run books three deliveries into the stock table, two of them for the same item, then
// adds two items only if they are new, and prints the stock that results.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives each upsert's outcome and the final stock.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	// ReceiveDelivery inserts a new item, or adds to the quantity of one already in stock.
	// EXCLUDED.quantity is the value the INSERT tried to write, so the parameters stay plain
	// Item and Quantity fields.
	deliveries := []store.ReceiveDeliveryParams{
		{Item: "cod", Quantity: 10},
		{Item: "potatoes", Quantity: 40},
		{Item: "cod", Quantity: 5},
	}
	for _, delivery := range deliveries {
		row, err := queries.ReceiveDelivery(ctx, delivery)
		if err != nil {
			return fmt.Errorf("receiving %s: %w", delivery.Item, err)
		}
		fmt.Fprintf(output, "%-8s delivery %d: +%d, now %d in stock\n",
			row.Item, row.Deliveries, delivery.Quantity, row.Quantity)
	}

	// AddItem uses DO NOTHING, and execrows reports whether the row went in: 1 for a new
	// item, 0 when the item was already there and the insert was skipped.
	for _, item := range []string{"peas", "cod"} {
		added, err := queries.AddItem(ctx, item)
		if err != nil {
			return fmt.Errorf("adding %s: %w", item, err)
		}
		if added == 0 {
			fmt.Fprintf(output, "%s is already stocked, nothing changed\n", item)
			continue
		}
		fmt.Fprintf(output, "added %s with no stock yet\n", item)
	}

	stock, err := queries.ListStock(ctx)
	if err != nil {
		return fmt.Errorf("listing stock: %w", err)
	}
	for _, row := range stock {
		fmt.Fprintf(output, "%-8s %2d\n", row.Item, row.Quantity)
	}
	return nil
}

What it prints

The second cod delivery adds to the first rather than failing on the primary key, and adding cod again changes nothing.

cod      delivery 1: +10, now 10 in stock
potatoes delivery 1: +40, now 40 in stock
cod      delivery 2: +5, now 15 in stock
added peas with no stock yet
cod is already stocked, nothing changed
cod      15
peas      0
potatoes 40

Try it

Give AddItem a RETURNING item clause and declare it one. When the item already exists, DO NOTHING skips the insert and RETURNING has no row to give back, so the method would return sql.ErrNoRows on a perfectly ordinary call. sgl vet warns about exactly that with Q046, and suggests optional: true, which returns (row, false, nil) instead, as in optional rows.

Across engines

Only these Go types of ReceiveDelivery differ between engines; the rest are the same everywhere.

PostgreSQLSQLiteMySQLDuckDB
quantity parameterint64int64int32int32
item columnstringstring·string
quantity columnint64int64·int32
deliveries columnint64int64·int32

A · marks a parameter or column that version of the example does not have.

CockroachDB and TimescaleDB run the PostgreSQL version unchanged.

MariaDB runs the MySQL version unchanged.

Compare the whole example side by side with SQLite, MySQL and DuckDB.

Where the example is missing:

  • Not on ClickHouse. ClickHouse has no upsert statement; ReplacingMergeTree collapses rows instead.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/postgres && go run ./021_upserts

Its test runs it too, and checks what it prints against output.txt:

cd seagull/examples/postgres && SGL_EXAMPLE_ENGINE=cockroachdb SGL_EXAMPLE_DSN="$DATABASE_URL" go test ./021_upserts

Needs a running CockroachDB server: set DATABASE_URL, or pass -dsn, to the address of an empty database.

The files

Every file of the example, as it is in the Seagull repository.

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: stock
    dialect: postgres
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      driver: database/sql
    migrate:
      dsn: ${env:DATABASE_URL}

db/migrations/0001_stock.up.sql

CREATE TABLE stock (
    item       TEXT   PRIMARY KEY,
    quantity   BIGINT NOT NULL,
    deliveries BIGINT NOT NULL DEFAULT 0
);

db/migrations/0001_stock.down.sql

DROP TABLE stock;

db/queries/stock.sql

-- sgl.query(ReceiveDelivery, one)
INSERT INTO stock (item, quantity, deliveries) VALUES ($1, $2, 1)
ON CONFLICT (item) DO UPDATE SET
    quantity   = stock.quantity + EXCLUDED.quantity,
    deliveries = stock.deliveries + 1
RETURNING item, quantity, deliveries;

-- sgl.query(AddItem, execrows)
INSERT INTO stock (item, quantity) VALUES ($1, 0)
ON CONFLICT (item) DO NOTHING;

-- sgl.query(ListStock, many)
SELECT item, quantity, deliveries FROM stock ORDER BY item;

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/postgres/021_upserts/internal/store"
)

// run books three deliveries into the stock table, two of them for the same item, then
// adds two items only if they are new, and prints the stock that results.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives each upsert's outcome and the final stock.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	// ReceiveDelivery inserts a new item, or adds to the quantity of one already in stock.
	// EXCLUDED.quantity is the value the INSERT tried to write, so the parameters stay plain
	// Item and Quantity fields.
	deliveries := []store.ReceiveDeliveryParams{
		{Item: "cod", Quantity: 10},
		{Item: "potatoes", Quantity: 40},
		{Item: "cod", Quantity: 5},
	}
	for _, delivery := range deliveries {
		row, err := queries.ReceiveDelivery(ctx, delivery)
		if err != nil {
			return fmt.Errorf("receiving %s: %w", delivery.Item, err)
		}
		fmt.Fprintf(output, "%-8s delivery %d: +%d, now %d in stock\n",
			row.Item, row.Deliveries, delivery.Quantity, row.Quantity)
	}

	// AddItem uses DO NOTHING, and execrows reports whether the row went in: 1 for a new
	// item, 0 when the item was already there and the insert was skipped.
	for _, item := range []string{"peas", "cod"} {
		added, err := queries.AddItem(ctx, item)
		if err != nil {
			return fmt.Errorf("adding %s: %w", item, err)
		}
		if added == 0 {
			fmt.Fprintf(output, "%s is already stocked, nothing changed\n", item)
			continue
		}
		fmt.Fprintf(output, "added %s with no stock yet\n", item)
	}

	stock, err := queries.ListStock(ctx)
	if err != nil {
		return fmt.Errorf("listing stock: %w", err)
	}
	for _, row := range stock {
		fmt.Fprintf(output, "%-8s %2d\n", row.Item, row.Quantity)
	}
	return nil
}

main.go

// Command 021_upserts applies its migrations to a PostgreSQL database, then runs the
// example in example.go: deliveries are booked with INSERT ... ON CONFLICT DO UPDATE, and
// new items are added with ON CONFLICT DO NOTHING.
//
// Run it with `go run . -dsn postgres://user:password@localhost:5432/example`, or set
// DATABASE_URL instead of passing -dsn. Give it an empty database of its own, as the
// migrations create its tables and the example adds rows each time it runs.
package main

import (
	"context"
	"database/sql"
	"embed"
	"errors"
	"flag"
	"fmt"
	"log"
	"os"

	_ "github.com/jackc/pgx/v5/stdlib"

	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_postgres"
)

// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS

func main() {
	dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "PostgreSQL connection string; defaults to $DATABASE_URL")
	flag.Parse()
	if err := execute(context.Background(), *dsn); err != nil {
		log.Fatal(err)
	}
}

// execute opens and migrates the database at dsn, and runs the example against it.
//
// Takes dsn (string) which is the PostgreSQL connection string.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, dsn string) error {
	if dsn == "" {
		return errors.New("set -dsn or DATABASE_URL to a PostgreSQL connection string")
	}
	database, err := sql.Open("pgx", dsn)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()

	migrator, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), migrations, "db/migrations")
	if err != nil {
		return err
	}
	if _, err := migrator.Up(ctx); err != nil {
		return fmt.Errorf("migrating: %w", err)
	}
	return run(ctx, database, os.Stdout)
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Stock struct {
	Item       string `json:"item"`
	Quantity   int64  `json:"quantity"`
	Deliveries int64  `json:"deliveries"`
}

internal/store/querier.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

func New(db DBTX) *Queries {
	wrapper, _ := db.(dbtxWrapper)
	return &Queries{reader: db, writer: db, wrapper: wrapper}
}

func NewWithReplica(writer DBTX, reader DBTX) *Queries {
	wrapper, _ := writer.(dbtxWrapper)
	return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

func (queries *Queries) wrapDBTX(db DBTX) DBTX {
	if queries.wrapper == nil {
		return db
	}
	wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
	if !ok {
		return db
	}
	return wrapped
}

func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
	transaction, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer transaction.Rollback()
	if err = fn(queries.WithTx(transaction)); err != nil {
		return err
	}
	return transaction.Commit()
}

internal/store/stock.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const addItemSQL = `INSERT INTO stock (item, quantity) VALUES ($1, 0)
ON CONFLICT (item) DO NOTHING;`

func (queries *Queries) AddItem(ctx context.Context, item string) (int64, error) {
	results, err := queries.writer.ExecContext(ctx, addItemSQL, item)
	if err != nil {
		return 0, err
	}
	return results.RowsAffected()
}

const listStockSQL = `SELECT item, quantity, deliveries FROM stock ORDER BY item;`

type ListStockRow struct {
	Item       string `json:"item"`
	Quantity   int64  `json:"quantity"`
	Deliveries int64  `json:"deliveries"`
}

func (queries *Queries) ListStock(ctx context.Context) ([]ListStockRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listStockSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListStockRow
	if rows.Next() {
		var row ListStockRow
		scanArgs := []any{&row.Item, &row.Quantity, &row.Deliveries}
		for more := true; more; more = rows.Next() {
			row = ListStockRow{}
			if err := rows.Scan(scanArgs...); err != nil {
				return nil, err
			}
			results = append(results, row)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

const receiveDeliverySQL = `INSERT INTO stock (item, quantity, deliveries) VALUES ($1, $2, 1)
ON CONFLICT (item) DO UPDATE SET
    quantity   = stock.quantity + EXCLUDED.quantity,
    deliveries = stock.deliveries + 1
RETURNING item, quantity, deliveries;`

type ReceiveDeliveryParams struct {
	Item     string
	Quantity int64
}

type ReceiveDeliveryRow struct {
	Item       string `json:"item"`
	Quantity   int64  `json:"quantity"`
	Deliveries int64  `json:"deliveries"`
}

func (queries *Queries) ReceiveDelivery(ctx context.Context, params ReceiveDeliveryParams) (ReceiveDeliveryRow, error) {
	var row ReceiveDeliveryRow
	err := queries.writer.QueryRowContext(ctx, receiveDeliverySQL, params.Item, params.Quantity).Scan(&row.Item, &row.Quantity, &row.Deliveries)
	if err != nil {
		return ReceiveDeliveryRow{}, err
	}
	return row, nil
}

output.txt

cod      delivery 1: +10, now 10 in stock
potatoes delivery 1: +40, now 40 in stock
cod      delivery 2: +5, now 15 in stock
added peas with no stock yet
cod is already stocked, nothing changed
cod      15
peas      0
potatoes 40

Also on other engines