47.66° N 117.43° W Summit · Profitable Growth
The Fields You Most Want to Change Are the Ones That Vanish
I moved a lead conversion action’s default value from 1.0 to 0.0 and the API told me it worked. It hadn’t. The value was still 1.0 a minute later, and the account carried on assigning a dollar of invented revenue to every lead it counted. That was one of three writes in five weeks, across three accounts, that came back successful and changed nothing.
Quick Take
A 200 is not proof that a URL is good, and a success response is not proof that a write landed. Same rule, one layer further in. On the Google Ads API the reason is specific. The helper Google documents for building a field mask compares values rather than presence, so a field you set to false, 0, 0.0 or "" looks unchanged to it and never reaches the mask, and Google ignores any field the mask doesn’t name. The writes that vanish are the ones setting something to off, zero, or empty.
Why off, zero and empty are the dangerous values
Start with the part Google publishes itself. The Python client library’s field-mask page hands you a helper, protobuf_helpers.field_mask, and documents what it does when the first argument is None. The mask it returns “will just contain all of the fields on the second protobuf object that are not set to their default value.”
Read that against what you were trying to do. You set target_cpa_micros to 0 because zero is how you say there is no target. The helper walks your object, finds 0, and reads it as a field you never touched. It leaves it out. Google’s side then ignores any field the mask doesn’t name, even though you sent the value, and returns a success, because the request was legal. Legality was never the thing in doubt.
Underneath that helper is a protobuf rule that explains part of it. In proto3 a plain scalar field carries no record of whether anyone set it, so “the default value is synonymous with ‘not present’ for purposes of serialization.” A 0 you meant and a 0 nobody touched are the same bytes. That covers the bid-strategy write, where target_cpa_micros is a plain int64.
It does not cover the other two, and this is the part worth knowing. Both of those fields are declared optional in the Google Ads protos, which means proto3 does track their presence and protobuf would have serialized them. They died anyway. Read the helper’s source and you find it comparing getattr values rather than calling HasField, so a tracked field you deliberately set to its default still reads as unchanged and still gets dropped. On the nested one it’s worse: the helper recurses into value_settings, finds nothing that differs, and the whole subtree disappears.
So the rule isn’t that protobuf drops defaults. It’s that the mask helper does, whether or not protobuf would have.
Here is why it lands on the writes you care about. Intent maps onto defaults. Turning something off is false. Removing a target is 0. Clearing a list is empty. Protobuf can’t tell subtraction apart from doing nothing, so every write where you take something away is a write at risk.
What it looked like in an account
The 1.0 to 0.0 write above is the clean case. I ran the mutate, got the success back, and only found it because I’d started querying the object again afterwards. The value sat there unchanged while Smart Bidding kept treating a dollar a lead as revenue.
The second one showed its own failure in the response and I nearly missed it. I wrote a boolean false to demote a conversion action, and the update mask that came back on the operation held one entry: resource_name. The field I cared about wasn’t in it. The evidence was sitting in the response object the whole time, and I hadn’t been reading response objects, because they said success. Worth saying plainly that this particular field is the wrong one to be reasoning about at all, for reasons I’ve written up separately. What makes it a receipt here isn’t the field, it’s the one-entry mask.
None of this is unique to Google, and the neighbouring cases have their own causes. Staging a Tag Manager change through an API client, I sent four parameters on a Conversion Linker tag and read three back. The one that vanished was a plain string, not a boolean. The three survivors came back retyped as template, which stores a boolean false as the string "false", and a non-empty string is truthy in JavaScript, so there’s a live risk of the setting inverting. I reproduced the loss and the retype on demand rather than inferring them. I did not test what the container runtime does with the retyped value. A monitoring vendor’s v2 API does a blunter version: it accepts an SSL reminder setting, returns {"stat":"ok"}, and the value never moves.
Different causes, one class. The write is accepted and the system is unchanged.
If you are the one paying for this
You don’t need any of the above. The question worth asking whoever touches your account, whether that’s an agency, a tool, or an agent, is not whether they made the change. It’s what they read back afterwards to confirm it, and when. “The API returned success” is an answer about their request, not about your account. A change that silently failed looks identical to one that worked, from the dashboard and from the outside, for as long as nobody goes and looks.
This is the write-side half of the verification step in the STACK Audit. That pass checks what an account currently reports. This one is about whether the last person to change it did.
One footnote for engineers
Three habits close it, and only the third proves anything. The first one contradicts Google’s own Python sample, which builds the mask with protobuf_helpers.field_mask(None, campaign._pb). That sample is fine for adding a value and unsafe for removing one.
from google.protobuf import field_mask_pb2 # client.get_type("FieldMask") does not exist
# 1. State the paths. Never derive them when the value might be a default.
# For a message that has subfields, use the subfield path: masking the bare
# parent is rejected with FIELD_HAS_SUBFIELDS. Google's stated reason for
# that error is to stop you clearing subfields you never populated.
op.update.resource_name = svc.campaign_path(CID, CAMPAIGN_ID)
op.update.maximize_conversions.target_cpa_micros = 0
client.copy_from(op.update_mask, field_mask_pb2.FieldMask(
paths=["maximize_conversions.target_cpa_micros"]))
# 2. Probe an unfamiliar shape instead of spending a live mutate on a guess.
# validate_only belongs on the REQUEST OBJECT. As a method kwarg it raises
# TypeError before the call leaves your machine, which reads exactly like
# the API rejecting you.
req = client.get_type("MutateCampaignsRequest")
req.customer_id = str(CID) # proto-plus rejects an int here
req.validate_only = True
req.operations.append(op)
svc.mutate_campaigns(request=req)
# 3. Re-read the object with an independent query. This is the only proof.
Google’s own docs land in the same place on the first habit: if you mean to clear a field, name it in the mask. And validate_only does less than the name suggests. It checks the request is legal, then skips the execution and returns an empty response.
Note the shape of that. A successful validation returns nothing. So does a successful no-op. Emptiness is not information.
What it costs to leave alone
The cost isn’t the failed write. It’s the belief that it succeeded, which stops anyone from looking again.
An account in this state reports itself as configured. A bid strategy that was supposed to change is still the old one, doing what it did before, sometimes on a budget that was raised to suit the new one. A lead action still carries a made-up value that Smart Bidding reads as revenue and buys more of. Both are live bidding inputs, and neither surfaces as an error anywhere, because there wasn’t one.
It compounds under automation. A script or an agent making twenty writes in a batch reports twenty successes, and the ones that quietly did nothing are indistinguishable from the ones that worked. The more of your account maintenance runs through software, the more of it rests on a response that means “legal request” and gets read as “done.”
What I did not claim
I haven’t measured what any of these cost in spend. I know the writes didn’t land and I know the state they left the accounts in. I never ran a counterfactual on what different bidding would have bought. And the field-mask behavior is a property of proto3 and of how a mask gets built, not a bug in Google’s API, which does what its documentation says it does.
The Receipts
The three Google Ads instances ran between 30 July and 5 August 2026 across three accounts, through the Python client library on API v24. The fields were maximize_conversions.target_cpa_micros set to 0, conversion_action.primary_for_goal set to false, and value_settings.default_value moved from 1.0 to 0.0. On the second, the returned mask was ['resource_name']. The Tag Manager parameter loss was reproduced deliberately on 1 September 2026 in a throwaway workspace, four parameters sent and three returned, then reverted. The monitoring case is UptimeRobot’s v2 editMonitor, tested 15 September 2026, which returned {"stat":"ok"} for an SSL reminder flag that never changed on read-back; their v3 PATCH /monitors/{id} sets it.
Keep going
If this hit, the next two pieces in the same universe:
- A 200 Is Not Proof (The Redirect That Eats Your gclid). The read-side sibling, and where this rule got its name. A correct status code on a correct page, with the click identifier stripped on the way through.
- The field your Google Ads audit is reading is the wrong one. What happens when you read
primary_for_goalinstead of writing to it, and why it makes a clean account look broken.
Free PDF: The 25-page Tracking Stack. Writes like these land on Layer 05, the Google Ads layer, and show up wrong on Layer 08, reporting. No email gate.
If software has been changing your ad account, the thing worth knowing is what signal that account is feeding Smart Bidding today, whoever or whatever last touched it. That’s a read, not a write, and it’s the free account audit: send me the account and you’ll have it back in 24 hours.
More reading
-
A 200 Is Not Proof (The Redirect That Eats Your gclid)
A redirect can return a correct 301 to a correct page and still strip the gclid off every ad click that passes through it. The click lands, the conversion comes back attached to nothing. Here is the check.
-
Four Ways a Site Launch Lies to You
A site can go live rendering perfectly while its search index is missing, its generated files 404, and a build variable the bundler never saw breaks a feature. Four failures that all fired on one launch, and the script that ended them.
Want a review like this on your account?
Want this kind of review
on your account?
Thirty minutes on the phone. Same person on the call as on the work. Walk out with a clear set of next steps.