Where manual restructures stop scaling
A restructure is easy to describe and slow to do. One campaign per brand instead of one per historical accident, ad groups tightened around themes, keywords regrouped, negatives rebuilt so campaigns stop bidding against each other. In the interface, or in Google Ads Editor, that description turns into days of copying, pasting, renaming and re-checking.
For one account, once, that is fine. Across a dozen accounts, or one account with five markets, manual work stops being slow and starts being unreliable. Somewhere around the fortieth ad group a keyword list lands in the wrong place, and nobody notices until the search terms report goes strange two weeks later. That is the real argument for scripting restructures. Not speed. Repeatability: the script does exactly the same thing to account twelve as it did to account one.
The tutorial version and the real version
Every Google Ads API tutorial shows the same happy path: create a budget, create a campaign, create an ad group, one clean mutate call each, everything succeeds, the end. That version works right up until you point it at a live account.
A real restructure is hundreds or thousands of operations that depend on each other. The campaign has to exist before its ad groups, the ad groups before their keywords. Some operations will fail for reasons you could not have known in advance: a duplicate name left behind by an old paused campaign, a keyword that breaks the length rules, a character the validator dislikes in row 800 of the build sheet. And when something fails mid-build, "start again" is not an option on an account that is spending money while you work.
Two features of the API make this workable, and neither gets much space in the tutorials: partial failure handling and MutateOperation batching. I write this tooling in Python against version 30 of the official client library; the ideas carry to the other client libraries unchanged.
One setup note from experience: load credentials with GoogleAdsClient.load_from_storage() and do not pass a version argument there. Pinning the API version at load time has caused me more upgrade pain than it has ever saved.
Problem one: partial failure, or one bad row failing the batch
By default, a mutate request is atomic. Send 500 operations and if operation 214 is invalid, nothing is applied and you get an exception. Atomicity is sometimes what you want, but for bulk keyword work it means one bad row blocking 499 good ones, fixed one discovery at a time.
Partial failure flips that: valid operations commit, invalid ones come back as structured errors, and the response tells you which was which. The switch lives on the request itself:
request = client.get_type("MutateGoogleAdsRequest")
request.customer_id = customer_id
request.mutate_operations.extend(operations)
request.partial_failure = True
response = client.get_service("GoogleAdsService").mutate(request=request)
Note where partial_failure sits. It is not a keyword argument on the mutate call, and passing it as one gets you a TypeError. It is a field on the MutateGoogleAdsRequest object, easy to miss if you are used to libraries that flatten options into arguments. The pattern holds throughout: build the request object, set its fields, hand the whole thing to GoogleAdsService.mutate(request=request).
Making the errors actionable
Turning partial failure on is the easy half. The half that pays is parsing the response so a failure is a row you can fix rather than noise in a log. Failed operations come back inside partial_failure_error, and each error carries the index of the operation that caused it:
if response.partial_failure_error:
failure_type = client.get_type("GoogleAdsFailure")
for detail in response.partial_failure_error.details:
failure = type(failure_type).deserialize(detail.value)
for error in failure.errors:
index = error.location.field_path_elements[0].index
log_failure(index, error.error_code, error.message)
The index maps back to the operation list you built, and the operation list maps back to a source row in the build sheet. So the log line worth writing is not "an error occurred". It is "row 214 of the keywords tab failed: duplicate keyword in ad group X". One is archaeology. The other is a fix.
What it catches in practice
On a restructure for a multi-market industrial brand, the regrouping logic produced a couple of dozen keywords that normalised into duplicates within their new ad groups, out of a few thousand keyword operations. With partial failure on, the valid operations went in, the duplicates came back as a numbered list, the dedupe rule got fixed, and the corrected rows were resubmitted. Total cost: minutes.
The counterfactual is the point. In a manual rebuild those rows would not have thrown errors at all. They would have been silently skipped or pasted over, the account would have looked complete, and the gap would have surfaced weeks later as a hole in coverage nobody could explain. The API being strict is a feature. Partial failure is what makes the strictness usable at volume.
Problem two: batching with MutateOperation
The second thing the tutorials skip is how operations should travel. Firing them one at a time works in a demo and falls over at scale: thousands of round trips, rate limits, and a build that takes an hour has a thousand chances to be interrupted.
The API's answer is the MutateOperation wrapper. Each one holds exactly one operation of any resource type, and a single request carries a list of them: budgets, campaigns, ad groups, keywords and shared negative lists in one payload.
op = client.get_type("MutateOperation")
budget = op.campaign_budget_operation.create
budget.resource_name = f"customers/{customer_id}/campaignBudgets/-1"
budget.name = "Search | DE | 2026"
budget.amount_micros = 50_000_000
That -1 is a temporary resource ID. The next operation in the same request can create a campaign that points its campaign_budget at the -1 resource name, and the API resolves the reference at commit time. This is how a whole campaign tree goes up in one request.
Ordering is real
Operations in a request are processed in order, and a temporary ID only exists once the operation defining it has appeared. Reference -1 before the operation that creates it and the request fails. Reference it from a later request and that fails too, because temporary IDs are not remembered across requests. The rule is simple to state and easy to violate once a script is assembling operations from several sources: parents first, children after, within the same request.
Partial failure and temporary IDs pull against each other
Here is the trade-off that shapes the whole design. Google's guidance is explicit that partial failure should not be used on requests whose operations depend on each other, and the logic is sound: if the campaign creation fails, there is no good row-level answer for the ad groups that reference it. A chained create should live or die as one unit.
In practice that means restructures run in waves. Budgets and campaigns go in one pass, chained with temporary IDs, atomic. The response returns the real resource names in operation order, the script records them, and ad groups go in the next pass referencing real names, with partial failure on. Keywords and negatives follow the same way. Each wave is either safely atomic or safely row-level, and nothing is both.
Batch sizes and quotas
The hard ceiling is 10,000 operations per mutate request; past that the request is rejected outright. I keep batches in the low thousands for a duller reason: a partial failure report on 2,000 rows is readable, and a retry after a network wobble re-sends less. Mutate operations also count against the developer token's daily quota; Basic access gets 15,000 a day, which one healthy restructure can eat. For genuinely huge jobs the API has BatchJobService, which takes the same operations asynchronously.
One entry point matters more than it sounds. The client library also offers per-resource services, CampaignService, AdGroupService and the rest, each with their own mutate methods. I route everything through GoogleAdsService.mutate instead. Restructure scripts run unattended, and unattended scripts want one code path for retries, one for logging, one partial failure parser, whatever resource is inside the operation. Consistency here is not elegance. It is what makes the 2am failure debuggable at 9am.
End to end on a real account
The account that shaped most of this was that multi-market industrial brand: five markets, one agreed structure, one build sheet as the source of truth. Per market, the script ran four waves: budgets and campaigns chained and atomic, then ad groups, then keywords, then negatives, the later waves running partial failure and mapping errors back to sheet rows.
The first market surfaced the problems: the duplicate keywords mentioned above and a handful of rows breaking keyword length rules. Both were fixed in the sheet and the build re-run clean. Markets two through five ran with no changes to the script. The whole build came to several thousand operations, and the errors across all five markets fitted on one screen.
That last sentence is the point. A person with Editor can build one market perfectly well. What a person cannot do is guarantee that market five matches market one exactly, and produce a complete, numbered list of everything that did not go in and why.
Why script it at all
Scripting a restructure is a poor way to save time on a one-off. The first build costs more than doing it by hand, every time. The return arrives on repetition: the second market, the tenth account, the rebuild after a strategy change, each landing identical, logged and checkable. If your accounts get restructured once a decade, use Editor with my blessing. If structure is something you revisit, the script is the cheaper path and it is not close.
The research end of the same pipeline, getting keyword data into build sheets without a copy-paste boundary, is its own post: why I stopped using the Keyword Planner UI and built my own tooling.