Create-or-Update (Upsert)¶
Let the API handle idempotency for you.
The Pattern¶
Upsert (update + insert) operations atomically create a resource if it doesn't exist, or update it if it does. No check required, no race conditions.
flowchart LR
subgraph CBA["Check-Before-Act"]
A1[Check] --> A2{Exists?}
A2 -->|Yes| A3[Skip]
A2 -->|No| A4[Create]
end
subgraph UP["Upsert"]
B1[Upsert] --> B2[Done]
end
%% Ghostty Hardcore Theme
style A1 fill:#5e7175,color:#f8f8f3
style A2 fill:#fd971e,color:#1b1d1e
style A3 fill:#a7e22e,color:#1b1d1e
style A4 fill:#65d9ef,color:#1b1d1e
style B1 fill:#9e6ffe,color:#1b1d1e
style B2 fill:#a7e22e,color:#1b1d1e
Atomic by Design
Upsert eliminates race conditions because the check and action happen in a single atomic operation inside the API or database.
When to Use¶
Good Fit
- APIs that explicitly support upsert semantics
- Database operations with
ON CONFLICTclauses - Configuration management (desired state declarations)
- High-concurrency scenarios where race conditions matter
Poor Fit
- APIs that only support separate create/update endpoints
- Operations where create vs update have different side effects
- When you need to know whether a resource was created or updated
Examples¶
GitHub CLI Fallback Pattern¶
When true upsert isn't available, simulate it with create-or-update:
# Try create first, fall back to update
gh release create v1.0.0 --notes "Release notes" --target main 2>/dev/null || \
gh release edit v1.0.0 --notes "Release notes"
Git Config (Built-in Upsert)¶
# git config is inherently upsert - sets value regardless of prior state
git config user.email "bot@example.com"
git config --global core.autocrlf false
GitHub Labels¶
# Create label, or update if exists (using error suppression)
gh label create "automated" --color "0366d6" --description "Bot-generated" 2>/dev/null || \
gh label edit "automated" --color "0366d6" --description "Bot-generated"
Environment Variables in GitHub Actions¶
# GITHUB_ENV is upsert - setting a var overwrites any previous value
- run: echo "VERSION=1.0.0" >> "$GITHUB_ENV"
Kubernetes Apply (Declarative Upsert)¶
Kubernetes compares desired state to current state and reconciles. Create if missing, update if different, no-op if same.
Declarative Tools Are Upsert
Tools like kubectl apply, Crossplane compositions, and Ansible playbooks are all built around upsert semantics. Declare desired state, let the tool reconcile.
Crossplane (Infrastructure Upsert)¶
# Crossplane compositions are declarative upsert for cloud resources
apiVersion: storage.gcp.crossplane.io/v1beta1
kind: Bucket
metadata:
name: my-bucket
spec:
forProvider:
location: US
Database Examples¶
PostgreSQL ON CONFLICT¶
INSERT INTO settings (key, value)
VALUES ('theme', 'dark')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
SQLite INSERT OR REPLACE¶
INSERT OR REPLACE INTO cache (key, data, expires)
VALUES ('user:123', '{"name":"test"}', datetime('now', '+1 hour'));
Redis SET¶
GitHub Actions Examples¶
Upsert GitHub Secret¶
- name: Set repository secret
run: |
# gh secret set is upsert - creates or updates
gh secret set API_KEY --body "${{ secrets.API_KEY }}"
Upsert Repository Variable¶
- name: Set repository variable
run: |
# gh variable set is upsert
gh variable set DEPLOYMENT_ENV --body "production"
Environment File Updates¶
- name: Update .env values
run: |
# Using envsubst for idempotent config generation
envsubst < .env.template > .env
Simulating Upsert When Not Available¶
Try-Create-Then-Update¶
upsert_pr_label() {
local pr_number="$1"
local label="$2"
# Adding a label is idempotent in GitHub - no error if already present
gh pr edit "$pr_number" --add-label "$label"
}
Create-or-Skip-and-Update¶
upsert_branch() {
local branch="$1"
local target="$2"
# Create branch (ignore error if exists)
git branch "$branch" "$target" 2>/dev/null || true
# Reset to target (idempotent update)
git checkout -B "$branch" "$target"
}
API with Separate Endpoints¶
upsert_webhook() {
local url="$1"
local events="$2"
# Check if webhook exists
existing=$(gh api repos/:owner/:repo/hooks --jq ".[] | select(.config.url==\"$url\") | .id")
if [ -n "$existing" ]; then
# Update existing
gh api -X PATCH "repos/:owner/:repo/hooks/$existing" \
-f "config[url]=$url" \
-f "events[]=$events"
else
# Create new
gh api -X POST repos/:owner/:repo/hooks \
-f "config[url]=$url" \
-f "events[]=$events"
fi
}
Edge Cases and Gotchas¶
Different Create vs Update Semantics¶
Some APIs have different required fields for create vs update:
# Create requires all fields
gh issue create --title "Bug" --body "Description" --label "bug"
# Update only needs changed fields
gh issue edit 123 --add-label "priority"
Mitigation: Wrap in a function that normalizes the interface.
Partial Updates Overwriting Data¶
Upsert might overwrite fields you didn't intend to change:
# This overwrites the entire release, not just notes
gh release edit v1.0.0 --notes "New notes"
# What if there were assets attached? Still there, but other metadata might reset.
Mitigation: Read-modify-write when partial updates aren't supported.
Upsert with Side Effects¶
Creating a resource might trigger webhooks, notifications, or other side effects that don't fire on update:
# First run: creates issue, sends notification
# Second run: updates issue, no notification
gh issue create --title "Deploy failed" ...
Consideration: This is often desirable (no duplicate notifications), but be aware of it.
Anti-Patterns¶
Ignoring Return Values¶
# Bad: no idea if this created or updated
gh secret set API_KEY --body "$VALUE"
# Better: log what happened (if API supports it)
if gh secret set API_KEY --body "$VALUE" 2>&1 | grep -q "created"; then
echo "Secret created"
else
echo "Secret updated"
fi
Using Upsert When Create-Only is Intended¶
# Bad: accidentally overwrites existing release
gh release create v1.0.0 ... || gh release edit v1.0.0 ...
# If v1.0.0 exists, you might not want to modify it!
# Better: check-before-act if overwriting is dangerous
Assuming All APIs Are Upsert¶
# Dangerous assumption: not all create commands are idempotent
gh repo create my-repo # Fails if exists, doesn't update
Comparison with Other Patterns¶
| Aspect | Check-Before-Act | Upsert | Force Overwrite |
|---|---|---|---|
| Race condition safe | No | Yes | Yes |
| Requires API support | No | Yes | Depends |
| Know if created vs updated | Yes | Sometimes | No |
| Complexity | Low | Low | Low |
Summary¶
Upsert is the cleanest idempotency pattern when available.
Key Takeaways
- Prefer native upsert -
kubectl apply,git config,gh secret set - Simulate when needed - create-or-update fallback pattern
- Watch for side effects - create vs update may trigger different events
- Don't assume - verify your API actually supports upsert semantics