Skip to content
⚠️ This is a fork of lrstanley/entrest with additional features. For the official documentation, visit lrstanley.github.io/entrest.

Upsert & Replace Operations

entrest provides two PUT operations that combine create and update functionality:

  • OperationUpsert: Creates or partially updates an entity (PATCH-like semantics via PUT)
  • OperationCreateOrReplace: Creates or fully replaces an entity (true PUT semantics per RFC 7231)

Both operations work the same way:

  • If an entity with the specified ID does not exist, it will be created
  • If an entity with the specified ID exists, it will be updated (behavior differs between operations)

Choosing Between Upsert and Replace

OperationUpdate BehaviorUnprovided Optional FieldsUse Case
OperationUpsertPartial updatePreserved (retain existing values)When you want to update specific fields without affecting others
OperationCreateOrReplaceFull replacementCleared (set to NULL/default)When you want to ensure the entity matches exactly what you send

Requirements

To use upsert or replace operations in entrest, you need to meet the following requirements:

1. Enable Ent's Upsert Feature

Ent's upsert feature provides SQL ON CONFLICT / ON DUPLICATE KEY functionality. This feature is required for both OperationUpsert and OperationCreateOrReplace, as they use the OnConflictColumns() method under the hood to handle ID conflicts.

You can enable this feature in two ways:

Via CLI Flag

Terminal window
go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./ent/schema

Via Code in entc.go

internal/database/entc.go
func main() {
ex, err := entrest.NewExtension(&entrest.Config{
Handler: entrest.HandlerStdlib,
})
if err != nil {
log.Fatalf("creating entrest extension: %v", err)
}
err = entc.Generate(
"./database/schema",
&gen.Config{
Target: "./database/ent",
Schema: "github.com/example/app/internal/database/schema",
Package: "github.com/example/app/internal/database/ent",
Features: []gen.Feature{
gen.FeatureUpsert, // Enable upsert feature
},
},
entc.Extensions(ex),
)
if err != nil {
log.Fatalf("failed to run ent codegen: %v", err)
}
}

2. Entity Must Have an ID Field

Both OperationUpsert and OperationCreateOrReplace require entities to have an explicit ID field. The ID is provided via the URL path (e.g., PUT /pets/123) and is used as the conflict target for the operation.

You can define ID fields in several ways in Ent:

Integer ID (Auto-incrementing)

internal/database/schema/pet.go
// Fields of the Pet schema.
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.Int("id"),
field.String("name"),
// ... other fields
}
}

UUID ID

internal/database/schema/pet.go
import (
"github.com/google/uuid"
)
// Fields of the Pet schema.
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.UUID("id", uuid.UUID{}).
Default(uuid.New),
field.String("name"),
// ... other fields
}
}

String ID (e.g., Username)

internal/database/schema/user.go
// Fields of the User schema.
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("id").
NotEmpty().
Immutable().
Comment("The username (id) of the user."),
field.String("name"),
// ... other fields
}
}

The ID field will be used as the conflict target, meaning if an entity with the same ID exists, it will be updated; otherwise, a new entity will be created.

3. Enable Upsert or Replace

By default, neither OperationUpsert nor OperationCreateOrReplace is enabled. You can enable them in two ways:

Globally with Explicit Operations

Explicitly list the operations you want to enable globally:

ex, err := entrest.NewExtension(&entrest.Config{
Handler: entrest.HandlerStdlib,
DefaultOperations: []entrest.Operation{
entrest.OperationCreate,
entrest.OperationRead,
entrest.OperationUpdate,
entrest.OperationUpsert, // Enable upsert (partial updates) by default
entrest.OperationDelete,
entrest.OperationList,
},
})

Or use OperationCreateOrReplace instead if you prefer full replacement by default:

ex, err := entrest.NewExtension(&entrest.Config{
Handler: entrest.HandlerStdlib,
DefaultOperations: []entrest.Operation{
entrest.OperationCreate,
entrest.OperationRead,
entrest.OperationUpdate,
entrest.OperationCreateOrReplace, // Enable replace (full replacement) by default
entrest.OperationDelete,
entrest.OperationList,
},
})

Per-Entity via Annotations

Enable upsert or replace for specific entities using the WithIncludeOperations annotation.

Example: Entity with Upsert (partial updates)

internal/database/schema/user.go
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{
entrest.WithIncludeOperations(
entrest.OperationCreate,
entrest.OperationRead,
entrest.OperationUpdate,
entrest.OperationUpsert, // Partial updates
entrest.OperationDelete,
entrest.OperationList,
),
}
}

Example: Entity with Replace (full replacement)

internal/database/schema/post.go
func (Post) Annotations() []schema.Annotation {
return []schema.Annotation{
entrest.WithIncludeOperations(
entrest.OperationCreate,
entrest.OperationRead,
entrest.OperationUpdate,
entrest.OperationCreateOrReplace, // Full replacement
entrest.OperationDelete,
entrest.OperationList,
),
}
}

HTTP Endpoint Details

Both operations generate a PUT endpoint at /{entities}/{id}:

  • Method: PUT
  • Path: /{entities}/{id} (same as read, update, and delete operations)
  • Request Body: Similar to the create operation schema, but excludes the ID field (which comes from the URL)
  • Response: Returns the created or updated entity

Example Usage

OperationUpsert (Partial Updates)

First request - creates a new user:

Terminal window
curl --request PUT \
--url 'http://localhost:8080/users/john_doe' \
--header 'Content-Type: application/json' \
--data '{
"name": "John Doe",
"email": "john@example.com",
"description": "Software Engineer"
}'

Second request - updates only specified fields:

Terminal window
curl --request PUT \
--url 'http://localhost:8080/users/john_doe' \
--header 'Content-Type: application/json' \
--data '{
"name": "John Smith",
"email": "john.smith@example.com"
}'

With OperationUpsert, the description field is preserved because it wasn't included in the second request.

OperationCreateOrReplace (Full Replacement)

First request - creates a new post:

Terminal window
curl --request PUT \
--url 'http://localhost:8080/posts/12345' \
--header 'Content-Type: application/json' \
--data '{
"title": "My First Post",
"slug": "my-first-post",
"body": "This is the content."
}'

Second request - replaces the entire resource:

Terminal window
curl --request PUT \
--url 'http://localhost:8080/posts/12345' \
--header 'Content-Type: application/json' \
--data '{
"title": "Updated Post",
"body": "This is the updated content."
}'

With OperationCreateOrReplace, the slug field is cleared (set to NULL) because it wasn't included in the second request.

Behavior Details

Both operations share these characteristics:

  1. ID from URL: The entity ID always comes from the URL path parameter, not the request body.

  2. Conflict Detection: Both operations use the entity's ID field for conflict detection. If an entity with that ID exists, it will be updated; otherwise, it will be created.

  3. Eager Loading: After the operation completes, the entity is fetched with all configured eager-loaded edges, just like create and update operations.

See Also