Two read-only commands against the Google Play Developer API, launched in
parallel on the same app, and one of them died with API error 400: This Edit Nothing had deleted anything on purpose. The cause: calling
has been deleted.
edits.insert a second time on the same package discards the edit that was
already open, and the API only tells you when you next touch the old one.
Never run two Play API clients on the same app at the same time.
The setup
I publish an Android game with a small Python CLI built on
google-api-python-client. Every subcommand — even a read-only one like
« show the tracks » — follows the workflow the
Edits guide prescribes:
open an edit with
edits.insert,
read or modify tracks and listings inside it, then either commit or
delete it. An edit is a transactional snapshot of the app; nothing is live
until it is committed. Reads have to go through an edit too, because tracks
and store listings only exist as sub-resources of edits/{editId}.
To save time I ran two subcommands concurrently from a shell: one listing the
tracks, one diffing the 11 store listings against disk. Both use the same
service account and the same package name.
What I expected
Two independent edits. Each process gets its own editId from insert,
works on it, and deletes it in a finally block. Reads do not conflict with
reads; I saw no reason for one process to notice the other.
What actually happens
The listing diff finished normally. The track listing printed:
API error 400: This Edit has been deleted.
Running the same command alone, a minute later, worked. So I reproduced it
without the CLI, with two inserts in one process and a read of the first
edit afterwards:
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
PACKAGE = "com.example.app"
creds = service_account.Credentials.from_service_account_file(
"service-account.json",
scopes=["https://www.googleapis.com/auth/androidpublisher"])
edits = build("androidpublisher", "v3", credentials=creds,
cache_discovery=False).edits()
a = edits.insert(packageName=PACKAGE).execute()["id"]
b = edits.insert(packageName=PACKAGE).execute()["id"] # 2.2 s later
for name, eid in (("A", a), ("B", b)):
try:
edits.get(packageName=PACKAGE, editId=eid).execute()
print(f"get {name}: OK")
except HttpError as e:
print(f"get {name}: HTTP {e.resp.status} — {e.reason}")
Output:
get A: HTTP 400 — This Edit has been deleted.
get B: OK
Deleting A afterwards fails with the same 400; deleting B succeeds. The
second insert is enough on its own — no commit, no Console, no write of
any kind.

The fix
Treat the pair (service account, package name) as a resource that admits one
client at a time. In a shell that means ; instead of &; in CI it means
one job per app, or a lock around the API calls. In Python the cheapest
guard is a file lock keyed on the package name, taken before insert and
released after commit or delete:
import fcntl, os, tempfile
from contextlib import contextmanager
@contextmanager
def play_edit(edits, package):
lock_path = os.path.join(tempfile.gettempdir(), f"play-edit-{package}.lock")
with open(lock_path, "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX) # blocks until the other run ends
edit_id = edits.insert(packageName=package).execute()["id"]
try:
yield edit_id
finally:
try:
edits.delete(packageName=package, editId=edit_id).execute()
except Exception:
pass # already committed or superseded
With the two commands serialized, both ran cleanly — the track listing came
back and the 11-locale diff reported zero differences. Two processes using
the lock above on the same package each got their own edit and their own
get … OK; the second one simply waited for the first to finish.
Why it works
Play keeps at most one open edit per app. The
Edits guide says so
for the Console case only — « Using the Play Console to make changes while an
edit is in progress will discard your current edit » — and the
edits.insert reference says nothing beyond « Creates a new edit for an app. »
The behaviour is the same rule seen from the API side: a new insert is a new
transaction, and the server discards whatever transaction was pending, whoever
opened it. The old editId is not returned as « expired » or « not found »; it
is reported as deleted, which sends you looking for a delete call that
never happened.
Two consequences follow. First, abandoned edits need no cleanup: the next
insert supersedes them, which is why a missing .execute() on my wrapper’s
delete call went unnoticed for weeks — the request object was built and
never sent, and nothing ever broke. Second, parallelism has to happen across
packages, never within one. Two apps, two edits, no interference; one app,
two edits, the first one loses.
What I did not test
Only a service-account credential, only API v3 through
google-api-python-client 2.x, only edits opened by the same account. I did
not check whether an edit opened by a different service account on the same
app is discarded too (the Console statement suggests yes). I did not measure
how long an untouched edit survives on its own.
Facts
context: Google Play Developer API v3, two clients opening edits on the same package concurrently
problem: the second edits.insert discards the first edit; later calls on it return HTTP 400 "This Edit has been deleted."
solution: serialize all edit-based API calls per package (shell ;, one CI job per app, or a file lock around insert…commit/delete)
verified_on: 2026-08-30
applies_to: [Google Play Developer API v3, google-api-python-client 2.x, service-account auth]
does_not_apply_to: [different packages in parallel, methods that do not use edits]