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.

What the engine does with each DiffResult field What the engine does with each DiffResult field: DiffResult with 4 facets. DiffResult changes false skips update entirely replaces destroy and recreate the topic delete_before_replace invert the replacement order stables values known during preview
Four fields, four distinct engine behaviours — only changes is a boolean gate.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and a working dynamic.ResourceProvider subclass.
  • Documentation (or empirical evidence) for which fields the remote API rejects on update — usually a 409 or a 400 field is immutable response.
  • A disposable account or namespace on the remote service, because confirming a replacement decision means actually performing one.
  • pulumi preview --diff in 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.

Mutability of each topic property Mutability of each topic property: comparison across Remote rule, diff bucket. Property Remote rule diff bucket name unique, fixed replaces cleanup_policy fixed at create replaces partitions increase only conditional retention_ms freely mutable changes bootstrap_url server derived stables
The classification table is the specification; the diff method is a transcription of it.
# 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.

One property differs — what does diff return? One property differs — what does diff return?: choose among 4 options. Which bucket is it in? mutable changes only, updatein place increase only replaces if thevalue shrank immutable name it in replaces computed list it in stables
Each bucket maps to one branch, so a new property is classified once and coded once.
# 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.

The update path with a confirming re-read The update path with a confirming re-read: Pulumi engine → TopicProvider → streaming API. Pulumi engine TopicProvider streaming API update(id, old, new) PATCH changed fields 200 accepted GET topic live values UpdateResult(outs)
Reading the object back before returning outs keeps state truthful when the PATCH applies partially.
# 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.

Replacement ordering under a unique name Replacement ordering under a unique name: delete old topic then create new topic then rewrite outputs then producers reconnect delete old topic name freed create new topic same name accepted rewrite outputs dependents repointed producersreconnect downtime window ends
delete_before_replace trades a gap in availability for a name the service will accept.
# 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.

Isolating a preview that always replaces Isolating a preview that always replaces: preview reports replace → print old and new with types → identify the drifting key → normalize both sides → re-run preview → repeat. preview reportsreplace print old andnew with types identify thedrifting key normalize bothsides re-run preview
The loop converges quickly because each pass eliminates exactly one mismatched property.
# 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.