zoobzio December 12, 2025 Edit this page

Upserts

Recipe: Implement insert-or-update with PostgreSQL ON CONFLICT.

ASTQL supports PostgreSQL's ON CONFLICT clause for upsert operations.

Basic Upsert

Insert a row or update if it already exists:

func UpsertUser(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("email")] = instance.P("email")
    values[instance.F("username")] = instance.P("username")
    values[instance.F("updated_at")] = instance.P("now")

    return astql.Insert(instance.T("users")).
        Values(values).
        OnConflict(instance.F("email")).
        DoUpdate().
        Set(instance.F("username"), instance.P("username")).
        Set(instance.F("updated_at"), instance.P("now")).
        Build().
        Render(postgres.New())
}

// INSERT INTO "users" ("email", "updated_at", "username")
// VALUES (:email, :now, :username)
// ON CONFLICT ("email") DO UPDATE SET "updated_at" = :now, "username" = :username

DO NOTHING

Skip conflicting rows without updating:

func InsertIgnoreDuplicates(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("email")] = instance.P("email")
    values[instance.F("username")] = instance.P("username")

    return astql.Insert(instance.T("users")).
        Values(values).
        OnConflict(instance.F("email")).
        DoNothing().
        Render(postgres.New())
}

// INSERT INTO "users" ("email", "username")
// VALUES (:email, :username)
// ON CONFLICT ("email") DO NOTHING

Multi-Column Conflict

Handle conflicts on composite unique constraints:

func UpsertUserRole(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("user_id")] = instance.P("user_id")
    values[instance.F("role_id")] = instance.P("role_id")
    values[instance.F("granted_at")] = instance.P("now")

    return astql.Insert(instance.T("user_roles")).
        Values(values).
        OnConflict(instance.F("user_id"), instance.F("role_id")).
        DoUpdate().
        Set(instance.F("granted_at"), instance.P("now")).
        Build().
        Render(postgres.New())
}

// INSERT INTO "user_roles" ("granted_at", "role_id", "user_id")
// VALUES (:now, :role_id, :user_id)
// ON CONFLICT ("user_id", "role_id") DO UPDATE SET "granted_at" = :now

RETURNING with Upsert

Get the inserted or updated row:

func UpsertUserReturning(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("email")] = instance.P("email")
    values[instance.F("username")] = instance.P("username")

    return astql.Insert(instance.T("users")).
        Values(values).
        OnConflict(instance.F("email")).
        DoUpdate().
        Set(instance.F("username"), instance.P("username")).
        Build().
        Returning(instance.F("id"), instance.F("created_at"), instance.F("updated_at")).
        Render(postgres.New())
}

// INSERT INTO "users" ("email", "username")
// VALUES (:email, :username)
// ON CONFLICT ("email") DO UPDATE SET "username" = :username
// RETURNING "id", "created_at", "updated_at"

Batch Upsert

Insert multiple rows with conflict handling:

func BatchUpsertProducts(instance *astql.ASTQL, count int) (*astql.QueryResult, error) {
    query := astql.Insert(instance.T("products"))

    // Add multiple value sets
    for i := 0; i < count; i++ {
        values := instance.ValueMap()
        values[instance.F("sku")] = instance.P(fmt.Sprintf("sku_%d", i))
        values[instance.F("name")] = instance.P(fmt.Sprintf("name_%d", i))
        values[instance.F("price")] = instance.P(fmt.Sprintf("price_%d", i))
        query = query.Values(values)
    }

    return query.
        OnConflict(instance.F("sku")).
        DoUpdate().
        Set(instance.F("name"), instance.P("name_0")).  // Uses first row params
        Set(instance.F("price"), instance.P("price_0")).
        Build().
        Render(postgres.New())
}

// INSERT INTO "products" ("name", "price", "sku")
// VALUES (:name_0, :price_0, :sku_0), (:name_1, :price_1, :sku_1), ...
// ON CONFLICT ("sku") DO UPDATE SET "name" = :name_0, "price" = :price_0

Conditional Upsert

Update only certain fields based on values:

func UpsertWithCondition(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("product_id")] = instance.P("product_id")
    values[instance.F("quantity")] = instance.P("quantity")
    values[instance.F("updated_at")] = instance.P("now")

    // Only update if new quantity is greater
    // Note: This requires raw SQL for the WHERE clause on conflict
    return astql.Insert(instance.T("inventory")).
        Values(values).
        OnConflict(instance.F("product_id")).
        DoUpdate().
        Set(instance.F("quantity"), instance.P("quantity")).
        Set(instance.F("updated_at"), instance.P("now")).
        Build().
        Render(postgres.New())
}

Sync Pattern

Replace all rows for an entity (delete-then-insert pattern):

func SyncUserPreferences(instance *astql.ASTQL, prefCount int) ([]astql.QueryResult, error) {
    results := make([]astql.QueryResult, 0, 2)

    // 1. Delete existing preferences
    deleteResult, err := astql.Delete(instance.T("preferences")).
        Where(instance.C(instance.F("user_id"), astql.EQ, instance.P("user_id"))).
        Render(postgres.New())
    if err != nil {
        return nil, err
    }
    results = append(results, *deleteResult)

    // 2. Insert new preferences
    query := astql.Insert(instance.T("preferences"))
    for i := 0; i < prefCount; i++ {
        values := instance.ValueMap()
        values[instance.F("user_id")] = instance.P("user_id")
        values[instance.F("key")] = instance.P(fmt.Sprintf("key_%d", i))
        values[instance.F("value")] = instance.P(fmt.Sprintf("value_%d", i))
        query = query.Values(values)
    }

    insertResult, err := query.Render(postgres.New())
    if err != nil {
        return nil, err
    }
    results = append(results, *insertResult)

    return results, nil
}

Upsert with Timestamps

Common pattern: set created_at on insert, updated_at always:

func UpsertWithTimestamps(instance *astql.ASTQL) (*astql.QueryResult, error) {
    values := instance.ValueMap()
    values[instance.F("email")] = instance.P("email")
    values[instance.F("username")] = instance.P("username")
    values[instance.F("created_at")] = instance.P("now")  // Set on insert
    values[instance.F("updated_at")] = instance.P("now")  // Set always

    return astql.Insert(instance.T("users")).
        Values(values).
        OnConflict(instance.F("email")).
        DoUpdate().
        Set(instance.F("username"), instance.P("username")).
        Set(instance.F("updated_at"), instance.P("now")).  // Only update updated_at
        Build().
        Render(postgres.New())
}

Best Practices

1. Always Use Unique Constraints

ON CONFLICT requires a unique constraint or index:

-- Unique constraint
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);

-- Or unique index
CREATE UNIQUE INDEX idx_users_email ON users(email);

2. Be Explicit About Updated Fields

Only update fields that should change on conflict:

// Good: explicit updates
.DoUpdate().
    Set(instance.F("username"), instance.P("username")).
    Set(instance.F("updated_at"), instance.P("now"))

// Bad: might overwrite fields you want to preserve

3. Use RETURNING to Avoid Extra Queries

// Good: single query
.Returning(instance.F("id"))

// Less efficient: separate SELECT after INSERT

4. Consider DO NOTHING for Idempotency

When you only care about ensuring a row exists:

// Idempotent: insert if missing, ignore if exists
.OnConflict(instance.F("id")).DoNothing()