Proto-Plus, Not Protobuf: Four Errors That Eat a Morning of Google Ads API Work

Google Ads API Python Scripting Debugging
← All posts

The wrapper you are actually writing against

The google-ads Python library does not hand you protobuf directly. It wraps it in a layer called proto-plus, which makes API messages behave more like ordinary Python objects. Mostly that is a kindness. The problem is what you find when something breaks: the Stack Overflow answers, forum threads and older sample code that an error message search surfaces were largely written against raw protobuf conventions. That code compiles in your head, looks idiomatic, and fails at runtime with errors that read like typos.

This is a field guide to the specific places the wrapper diverges, written from actual error traces rather than the reference docs. It assumes you are already writing scripts against the API; if you want the case for doing that at all, start with what actually breaks when you restructure accounts via the API.

1. Repeated fields are lists, not containers with .add()

Raw protobuf lets you call .add() on a repeated field and get back a mutable object to fill in. Proto-plus exposes repeated message fields as RepeatedComposite, which behaves like a Python list, and the old idiom dies immediately:

# Fails: AttributeError: 'RepeatedComposite' object has no attribute 'add'
new_hl = rsa.headlines.add()
new_hl.text = "Your Headline"

The proto-plus way is to build the message first, then append it:

new_hl = client.get_type("AdTextAsset")
new_hl.text = "Your Headline"
rsa.headlines.append(new_hl)

Use .extend() when copying a full set of headlines or descriptions across from a source ad, and plain .append() with bare values for scalar repeated fields such as final_urls. Google's own migration notes confirm the pattern: create separately, then append. The answers telling you to call .add() are not wrong, they are just answering a different library.

2. FieldMask cannot come from the client

Every update operation needs a field mask, and client.get_type("FieldMask") looks like it should work by symmetry with every other type you have requested that way. It does not. FieldMask is a well-known protobuf type, not a Google Ads resource, so it never enters the proto-plus type registry the client draws from. Import the protobuf module directly:

from google.protobuf import field_mask_pb2

fm = field_mask_pb2.FieldMask()
fm.paths.append("field_name")
op.update_mask.CopyFrom(fm)

For masks derived from a whole modified message, the client library docs route you through the field_mask helper in google.api_core.protobuf_helpers instead, which diffs two messages and builds the paths for you. Both work. The one thing that never works is asking the Ads client for the type.

3. Enum getters drift between client versions

A trace worth recognising on sight:

AttributeError: '_EnumGetter' object has no attribute 'ContainsEuPoliticalAdvertisingEnum'

The context here is the EU political advertising declaration. From 1 April 2026, an account with undeclared campaigns has its campaign management mutate calls rejected with EU_POLITICAL_ADVERTISING_DECLARATION_REQUIRED, so every build script suddenly needed to set the declaration field. That field arrived across client versions v19.2, v20.1 and v21, and the enum is named differently depending on which version you are pinned to. The code samples circulating still reference the earlier name.

The general lesson matters more than this one field. _EnumGetter raises AttributeError for any name that does not exist in your installed version, and the message gives you no version context at all. The error reads like a typo when it is actually version drift. Three ways out when it bites:

  • Upgrade the client and take the current name.
  • Check the actual enum name in the installed package, with dir() on client.enums, rather than trusting a search result written against a different version.
  • Set the field directly to its string or integer value and skip the enum getter entirely.

4. Ad type sub-objects, and the read/write asymmetry

Creating a responsive search ad means declaring the ad type by writing to the sub-object, because ad is a generic container that could hold a Display, Video or Search ad:

ad_op.create.ad.responsive_search_ad.path1 = "solicitors"

On the read side, the GAQL field path is ad_group_ad.ad.responsive_search_ad.path1. Some fields are set at ad level on creation and read from the sub-object on query, and this asymmetry is exactly where copy-an-existing-ad scripts go quiet: the script runs, the ads go live, and the paths are empty or the headlines malformed because a field was read from one shape and written to another. If a copied ad is missing pieces, diff the GAQL paths you read against the object paths you wrote before suspecting anything else.

5. The error that ties it all together

The recurring failure mode across all of this is the least helpful error class in the API: required field not present, with no field path and no line reference. In practice it is nearly always one of two things: a missing sub-object declaration of the kind above, or a mandatory field added in a recent API version that your script predates, the EU declaration being the current example.

The debugging order that saves the morning:

  1. Check the field exists in your installed client version, in the package itself, not in a search result.
  2. Check whether the field belongs on the sub-object rather than the parent.
  3. Check the release notes for a newly mandatory declaration.

To be clear about what upgrading buys you: a newer client does not make these errors better. The traces stay terse and version-blind. What changes is whose vocabulary you are debugging in, so the answers you find online start matching the library you are actually running.

The query side of the same library has its own set of silent divergences, covered in GAQL looks like SQL, and that is the problem.

Scripts against the Ads API misbehaving?

I build and debug Google Ads API tooling as one-off projects: restructure scripts, reporting pipelines, keyword tooling. You get working code and a written handover, not a retainer.

Get in Touch