Handling Diffs and Updates in Pulumi Dynamic Providers
diff is the method that decides whether an edit costs a field update or a full teardown, and Pulumi trusts whatever it returns without checking. Written as part of Dynamic Providers and Custom Resources in Pulumi inside Pulumi Patterns & Provider Management, this guide works through the four DiffResult fields, the mutability classification that drives them, the UpdateResult contract, and the specific debugging steps for a provider that wants to replace something on every preview.
Context
The worked example is a topic on a managed streaming service. It exposes four inputs and two server-computed values, and each one behaves differently when edited:
| Property | Remote behaviour |
|---|---|
name |
fixed at creation, globally unique per account |
cleanup_policy |
compact or delete, fixed at creation |
partitions |
can be increased online, never decreased |
retention_ms |
freely mutable |
bootstrap_url |
derived from the account, never changes |
created_at |
set once by the service |
Nothing in the Pulumi SDK knows any of that. The engine hands the provider an old dictionary and a new dictionary and asks a question; the answer determines whether operators see ~ update or +- replace in the preview. Getting partitions wrong in the "decrease" direction means the service silently refuses the change; getting name wrong in the other direction means the topic and all of its retained messages are deleted.
Prerequisites
- Python 3.9+ with
pulumi>=3.0and a workingdynamic.ResourceProvidersubclass. - Documentation (or empirical evidence) for which fields the remote API rejects on update — usually a
409or a400 field is immutableresponse. - A disposable account or namespace on the remote service, because confirming a replacement decision means actually performing one.
pulumi preview --diffin your muscle memory; it is the only readout of what these methods decided.
# CLI: capture the engine's view before touching diff, so you have a baseline
pulumi preview --diff --json > /tmp/before.json
Implementation
1. Classify every property before writing any code
Build the table first. Every property lands in exactly one of four buckets, and the bucket determines the code. This step is where the design happens; the rest is transcription.
# providers/topic.py — deployed with: pulumi up
from typing import Any, Dict, FrozenSet
# Provider note: these three sets ARE the diff specification.
IMMUTABLE: FrozenSet[str] = frozenset({"name", "cleanup_policy"})
MUTABLE: FrozenSet[str] = frozenset({"retention_ms", "partitions"})
SERVER_COMPUTED: FrozenSet[str] = frozenset({"bootstrap_url", "created_at"})
def _normalize(props: Dict[str, Any]) -> Dict[str, Any]:
"""Coerce both sides into one shape so equality means equality."""
out = dict(props)
if "retention_ms" in out:
out["retention_ms"] = int(out["retention_ms"]) # API returns a string
if "partitions" in out:
out["partitions"] = int(out["partitions"])
if "cleanup_policy" in out:
out["cleanup_policy"] = str(out["cleanup_policy"]).lower()
return out
_normalize is not optional decoration. The remote API returns "retention_ms": "604800000" as a string while the program supplies an integer, and comparing those two directly reports a change on every single preview forever.
2. Return a DiffResult that says exactly what changed
DiffResult has four fields and each one instructs the engine to do something different. changes is a boolean gate: if it is False, the engine skips the resource entirely and update is never called. replaces is a list of input property names that forced a replacement — non-empty means destroy and recreate. delete_before_replace flips the ordering of that replacement. stables lists properties the engine can treat as known during a preview even though an update is pending, which keeps downstream previews readable instead of full of output<string> placeholders.
# providers/topic.py — CLI: pulumi preview --diff
from typing import Any, Dict, List
from pulumi.dynamic import DiffResult, ResourceProvider
class TopicProvider(ResourceProvider):
def diff(self, id_: str, old: Dict[str, Any], new: Dict[str, Any]) -> DiffResult:
o, n = _normalize(old), _normalize(new)
compared = (IMMUTABLE | MUTABLE) & set(n)
changed: List[str] = [k for k in sorted(compared) if o.get(k) != n.get(k)]
replaces: List[str] = [k for k in changed if k in IMMUTABLE]
# Partitions increase online; a decrease is impossible, so it forces a replacement.
if "partitions" in changed and n["partitions"] < o.get("partitions", 0):
replaces.append("partitions")
return DiffResult(
changes=bool(changed),
replaces=sorted(replaces),
# Provider note: the topic name is unique account-wide, so the old one must go first.
delete_before_replace="name" in replaces,
stables=sorted(SERVER_COMPUTED),
)
Three details matter here. compared is intersected with the new inputs so a property absent from the program is not read as "changed to None". Server-computed values are never compared, because they appear in old (they were written to state by create) and never in new. And replaces carries property names, not a boolean — the engine prints them in the preview, so an accurate list is what tells an operator why their edit is destructive.
3. Implement update to return the complete new outs
UpdateResult has one field, outs, and its contract is absolute: whatever you return replaces the resource's entire state entry. It is not merged with the previous outs. Returning only the properties you touched silently erases bootstrap_url, created_at, and every other value the resource ever exported.
# providers/topic.py — CLI: pulumi up
from pulumi.dynamic import UpdateResult
class TopicProvider(ResourceProvider):
def update(self, id_: str, old: Dict[str, Any], new: Dict[str, Any]) -> UpdateResult:
import requests
o, n = _normalize(old), _normalize(new)
body: Dict[str, Any] = {}
if n.get("retention_ms") != o.get("retention_ms"):
body["retention_ms"] = n["retention_ms"]
if n.get("partitions") != o.get("partitions"):
body["partitions"] = n["partitions"]
if body:
r = requests.patch(f"{API_BASE}/topics/{id_}", json=body,
headers=self._headers(n), timeout=30)
if r.status_code == 409:
raise Exception(f"topic {id_}: {r.json().get('message', 'conflict')}")
r.raise_for_status()
# Provider note: re-read instead of trusting the request body — a PATCH can apply partially.
live = requests.get(f"{API_BASE}/topics/{id_}",
headers=self._headers(n), timeout=30).json()
# State implication: these outs REPLACE the state entry wholesale, not merge into it.
return UpdateResult(outs={
**n,
"topic_id": id_,
"partitions": int(live["partitions"]),
"retention_ms": int(live["retention_ms"]),
"bootstrap_url": live["bootstrap_url"],
"created_at": live["created_at"],
})
The re-read is what makes the method honest about partial application. This service applies a partition increase asynchronously: the PATCH returns 200 and the retention change lands immediately, but the partition count updates seconds later. If update returned the requested partitions value, state would claim success while the topic still had the old count, and the next preview would show nothing wrong. Reading the live object back means state records what is actually true, and the following preview correctly proposes the remaining change.
That behaviour also makes update safely re-runnable. A retried invocation computes an empty body (because old now matches the live object), skips the PATCH, and returns the same outs — which is the practical definition of idempotency for a lifecycle method that a flaky network can invoke twice.
4. Order the replacement with delete_before_replace
The default replacement order is create-then-delete: the engine builds the new resource, repoints dependents, then removes the old one. That is the safer order and it is what you want for anything with a generated identifier. It is impossible when the remote system enforces uniqueness on a property you control, because the create half fails against the object that still exists.
# CLI: what the service returns when the default ordering is used on a unique name
$ pulumi up
error: topic "orders-events" already exists in account acct_8812 (409)
Setting delete_before_replace=True in the DiffResult inverts the order. Accept the consequence: there is a window in which the topic does not exist, producers get UNKNOWN_TOPIC_OR_PARTITION, and retained data is gone. Return it only when replaces contains the property that carries the uniqueness constraint — computing it as "name" in replaces rather than hard-coding True keeps a cleanup_policy change on the safer ordering.
Verification
Confirm each decision empirically. Edit one property at a time and read the preview's verb.
# CLI: a mutable edit must show ~ update, never +- replace
pulumi config set topic:retentionMs 259200000 && pulumi preview --diff | grep -E '~|\+-'
# CLI: an immutable edit must show the property name in the replace reason
pulumi preview --diff | grep -A3 'replace'
# CLI: the definitive check — apply, then preview again with no source change
pulumi up --yes && pulumi preview --diff
The last command is the one that catches normalization bugs. A clean program must produce no changes; anything else means diff is comparing a value against its own round-tripped form. Pair these manual checks with the parametrized cases described in testing Pulumi dynamic providers in Python so the decisions stay pinned once you have established them.
Operational Notes
A provider that reports a replacement on every preview has one of a small number of causes, and they are quick to separate if you print both dictionaries before comparing them.
# providers/topic.py — CLI: pulumi preview --logtostderr -v=3 2>&1 | grep DIFF
import sys
def _debug_diff(o: Dict[str, Any], n: Dict[str, Any]) -> None:
for key in sorted(set(o) | set(n)):
ov, nv = o.get(key), n.get(key)
if ov != nv:
print(f"DIFF {key}: old={ov!r} ({type(ov).__name__}) "
f"new={nv!r} ({type(nv).__name__})", file=sys.stderr)
Read the types, not just the values. old='3' (str) new=3 (int) is the string-versus-integer case that _normalize exists to fix. old=None (NoneType) new='delete' (str) means the property was added to the resource class after the state entry was written, so old predates it — handle it with o.get(k, n.get(k)) for one release, or refresh the stack. old=[...] new=[...] on lists that hold the same items in a different order needs a sorted() in the normalizer. A diff on a key you never declared, such as __provider, means the comparison is iterating the whole dictionary instead of the intersected property set from step 1.
If the replacement is real but unwanted, pulumi up --target-replace and resource options like ignore_changes can move a specific resource forward without rewriting the provider, and the state surgery techniques in managing IaC state cover the case where a bad replaces list has already destroyed something.
Gotchas & Edge Cases
changes=False skips update entirely. If a provider computes a real API call inside update but returns changes=False for that property, the call never happens and there is no error — the preview simply says nothing to do.
replaces must name inputs, not outputs. Listing bootstrap_url in replaces produces a replacement the operator cannot explain, because no input they edited appears in the reason.
stables is a promise, not a hint. A property listed there must genuinely never change during an update. If bootstrap_url can move when partitions grow, listing it as stable makes downstream resources consume a value the engine believed was fixed.
Secrets always look changed. A secret input arrives as a plain value inside the provider but may round-trip through state differently. Compare a hash of the secret rather than the value, and keep only the hash in outs.
An empty replaces with delete_before_replace=True does nothing. The flag only takes effect when a replacement is happening; setting it unconditionally is harmless but misleading to the next reader.
Downstream dependents amplify a replacement. Anything referencing the topic's outputs is replaced or updated in the same operation, so one wrong immutability classification can cascade through several resources in a single pulumi up.
FAQ
What is the difference between replaces and returning changes=True?
changes=True alone routes the edit to update, which mutates the existing remote object in place. replaces names properties that cannot be mutated, so the engine destroys the resource and creates a new one. Both can be true at once, and when they are, the replacement wins.
Why does my preview say "replace" when I only changed a tag?
Almost always a normalization problem rather than a real immutability rule: the remote API returned the tag map with different key ordering or a coerced value type, and the raw comparison flagged it. Print old and new with their types, then coerce both sides before comparing.
Do I have to implement diff at all?
No — without it Pulumi falls back to a structural comparison of inputs and treats every difference as an in-place update. That is wrong for any resource with immutable fields, so implement diff as soon as the remote API rejects an update on any property.
How do I make an update idempotent when the API applies changes partially?
Build the request body by comparing old against new, then re-read the object and construct outs from the live response rather than from the request. A retry then computes an empty body, performs no call, and returns identical state.
When should delete_before_replace be set?
Only when the remote system enforces uniqueness on a property that is forcing the replacement — a globally unique name, a fixed port, a singleton binding. Everything else is safer with the default create-then-delete ordering.
Can I stop the engine replacing a resource I know is fine?
Yes, with the ignore_changes resource option on the specific property, or by correcting the classification in the provider so diff stops reporting it. Prefer fixing the provider; ignore_changes hides real drift as well as false drift.
Related
- Testing Pulumi Dynamic Providers in Python — how to pin these replace decisions with fast offline tests.
- Writing a Pulumi Dynamic Provider in Python — the full CRUD implementation these methods belong to.
- Dynamic Providers and Custom Resources in Pulumi — the parent topic covering when a dynamic provider is the right tool.
- Managing IaC State — recovering when a replacement has already written the wrong thing to state.