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
| Operation | Update Behavior | Unprovided Optional Fields | Use Case |
|---|---|---|---|
OperationUpsert | Partial update | Preserved (retain existing values) | When you want to update specific fields without affecting others |
OperationCreateOrReplace | Full replacement | Cleared (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
go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./ent/schemaVia Code in 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)
// Fields of the Pet schema.func (Pet) Fields() []ent.Field { return []ent.Field{ field.Int("id"), field.String("name"), // ... other fields }}UUID ID
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)
// 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)
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)
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:
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:
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:
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:
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:
-
ID from URL: The entity ID always comes from the URL path parameter, not the request body.
-
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.
-
Eager Loading: After the operation completes, the entity is fetched with all configured eager-loaded edges, just like create and update operations.
See Also
- Annotation Reference - For more details on
WithIncludeOperationsand other annotations - Configuration - For global configuration options