How-to
Automating Reporting WorkflowsDeep dive

Upload an Excel Report to Google Cloud Storage

Publish generated workbooks to a Cloud Storage bucket from Python: authentication without key files, dated object names, the correct content type and metadata, uploads that refuse to overwrite, checksummed resumable uploads for large files, time-limited download links, retention rules, and local testing against an emulator.

When reporting jobs run on Google Cloud — Cloud Run, Cloud Functions, Composer, a GKE CronJob — Cloud Storage is where their output naturally lands: durable, cheap, versionable, and readable by BigQuery, Looker Studio and anyone given access. The client library makes the upload itself one call. The decisions around it are what make a reporting bucket pleasant to live with: how objects are named so that runs do not overwrite each other, what metadata travels with the file, how a large upload is checked, how people without Google Cloud access get the file, and how old reports are cleaned up. This page, part of Publishing Excel Reports to Cloud Storage, goes through them with code that runs unchanged against a local emulator.

Prerequisites

Bash
pip install google-cloud-storage pandas openpyxl
# for local testing
docker run -d --rm -p 4443:4443 fsouza/fake-gcs-server -scheme http -port 4443
export STORAGE_EMULATOR_HOST=http://127.0.0.1:4443

The code on this page ran against fake-gcs-server, an open-source Cloud Storage emulator: with STORAGE_EMULATOR_HOST set, the client library talks to it instead of Google, and uses anonymous credentials. Unset the variable and the same code talks to the real service. The bucket acme-reports exists; weekly_sales.xlsx is this week's report and transactions_export.xlsx a larger file for the resumable upload.

From a finished workbook to a governed object The job authenticates with Application Default Credentials, names the object by report and date, uploads with the right content type and metadata and a no-overwrite precondition, and hands out a time-limited link; a lifecycle rule deletes old reports. Credentials ADC, no key file Name by date sales/2026-09-28/… Upload type, metadata, no overwrite Share signed URL Retain lifecycle rule

Step 1 — Authenticate without key files

The client finds credentials through Application Default Credentials: the service account attached to a Cloud Run service or VM, gcloud auth application-default login on a developer machine, or workload identity on GKE. No JSON key file needs to be downloaded, stored or rotated:

Python
from google.cloud import storage

client = storage.Client(project="acme-analytics")
bucket = client.bucket("acme-reports")
print(type(client._credentials).__name__, [b.name for b in client.list_buckets()])

Against the emulator the credentials are anonymous; on Google Cloud they are the job's service account, which needs a role that allows creating objects in the bucket — roles/storage.objectCreator is enough to upload, plus objectViewer if the job also reads. Key files still work (GOOGLE_APPLICATION_CREDENTIALS=/path/key.json) but are the thing security teams most want to eliminate; see Store Credentials for Excel Report Scripts Safely.

Step 2 — Name objects by report and date

Cloud Storage has no folders — only object names with slashes in them, which the console displays as folders. Put the report name and the reporting date in the name, so every run gets its own object and listing a prefix gives the history:

Python
from datetime import date

def object_name(report, run_date, filename):
    return f"{report}/{run_date:%Y-%m-%d}/{filename}"

name = object_name("sales", date(2026, 9, 28), "weekly_sales.xlsx")
print(name)
How dated object names look in the bucket The acme-reports bucket fans out to objects named by report and date. Each weekly sales run has its own dated prefix, a separate latest copy is optional, and the transactions export lives under its own report prefix. acme-reports one bucket for all reports sales/2026-09-21/weekly_sales.xlsx last week sales/2026-09-28/weekly_sales.xlsx this week sales/latest/weekly_sales.xlsx optional fixed name transactions/2026-09-28/… another report

Dated names make reruns and backfills safe — a rerun for last Monday writes to last Monday's name — and make "the file from four weeks ago" a matter of listing. A fixed name that is overwritten every week is simpler for people who bookmark it; if you need both, write the dated object and also copy it to sales/latest/weekly_sales.xlsx.

Step 3 — Upload with content type, metadata and a precondition

Set the content type explicitly, attach metadata describing where the file came from, and add a precondition so an accidental second run cannot overwrite a report that was already published:

Python
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

def publish(bucket, local_path, name, metadata, overwrite=False):
    blob = bucket.blob(name)
    blob.metadata = metadata
    blob.upload_from_filename(local_path, content_type=XLSX, checksum="md5",
                              if_generation_match=None if overwrite else 0)
    return bucket.get_blob(name)

meta = {"report": "weekly-sales", "source": "erp-export", "rows": "4"}
blob = publish(bucket, "weekly_sales.xlsx", name, meta)
print(blob.name, blob.size, blob.content_type)
print(blob.metadata, "md5", blob.md5_hash)

if_generation_match=0 means "only if no object with this name exists"; a second attempt fails with a 412 Precondition Failed instead of silently replacing the published file. checksum="md5" makes the library compare the checksum the service computed with the local file and raise if they differ, so a corrupted upload never passes as success. The explicit content type means browsers and download tools open the file as a workbook rather than guessing from the name.

Python
from google.api_core.exceptions import PreconditionFailed

try:
    publish(bucket, "weekly_sales.xlsx", name, meta)
except PreconditionFailed as err:
    print("already published:", err.code)
blob = publish(bucket, "weekly_sales.xlsx", name, meta, overwrite=True)
print("replaced deliberately, generation", blob.generation)

A deliberate rerun passes overwrite=True. With object versioning enabled on the bucket, the replaced version is kept as a noncurrent version, so even deliberate overwrites can be undone.

What if_generation_match=0 does on upload A decision diagram. If no object with the name exists the upload succeeds and creates generation one; if one exists the service rejects the upload with 412 Precondition Failed; a deliberate rerun omits the precondition and replaces the object. Does the object already exist? no Created new generation yes 412 Precondition Failed published file untouched deliberate rerun overwrite=True replaces the object

Step 4 — Large files: resumable, checksummed uploads

Files above a few megabytes are uploaded in chunks through a resumable upload, which can pick up where it left off after a network error. The library does this automatically for large files; set chunk_size to control it — it must be a multiple of 256 KB:

Python
big = bucket.blob(object_name("transactions", date(2026, 9, 28), "transactions_export.xlsx"))
big.chunk_size = 1024 * 1024
big.upload_from_filename("transactions_export.xlsx", content_type=XLSX, checksum="md5", if_generation_match=0)
stored = bucket.get_blob(big.name)
print(stored.name, f"{stored.size:,} bytes")

The 3 MB file went up in three 1 MB chunks and was checksummed as a whole once the last chunk arrived. Smaller chunks mean less to resend after a failure and more requests overall; 8–16 MB suits large exports on a reliable network.

Colleagues without Google Cloud access can download a report through a signed URL: a link that carries a signature granting read access to one object until it expires. Signing needs a service-account identity, so it does not work with anonymous emulator credentials — the code is shown as it runs in production:

Python
from datetime import timedelta

url = bucket.blob(name).generate_signed_url(version="v4", expiration=timedelta(days=7), method="GET",
                                             response_disposition='attachment; filename="weekly_sales.xlsx"')
print(url)

Version 4 signed URLs can be valid for at most seven days. On Cloud Run or Compute Engine, where the credentials have no private key, pass service_account_email and an access_token so the library signs through the IAM API instead. response_disposition makes the browser download the file with a clean name instead of showing the long object path. Emailing the link rather than the file keeps large reports out of mailboxes; Send an Excel Report with Gmail covers sending it.

Step 6 — Delete old reports automatically

A reporting bucket grows forever unless something deletes old objects. A lifecycle rule on the bucket does it without any code running: delete objects older than a set age, or only those under a prefix:

Python
bucket = client.get_bucket("acme-reports")
bucket.add_lifecycle_delete_rule(age=400, matches_prefix=["sales/"])
bucket.patch()
print(list(bucket.lifecycle_rules))

Four hundred days keeps a year of weekly reports plus a margin for year-on-year comparisons. The emulator used for this page accepts the call but does not store lifecycle rules, so this step was not exercised locally — check the bucket's lifecycle configuration in the console after applying it.

Performance notes

A weekly workbook of a few megabytes uploads in about a second from inside Google Cloud; most of the time is TLS setup and authentication, so reuse one client for every upload in a run rather than creating one per file. For many small files — one workbook per region or per customer — upload them in parallel from a small pool of workers, giving each worker its own client. Uploads from outside Google Cloud are limited by the local connection, and the resumable protocol is what keeps a slow or flaky link from restarting a large file from zero.

Common pitfalls

  • Downloading service-account keys. Use Application Default Credentials; keys are a liability.
  • One fixed object name for every run. Reruns overwrite history; name objects by report and date.
  • No content type. Browsers may treat the file as generic binary; set the .xlsx type explicitly.
  • Silent overwrites. Use if_generation_match=0 for publish-once objects.
  • Signed URLs from credentials without a key. Sign through IAM with service_account_email and access_token on Cloud Run and GCE.

Conclusion

Let Application Default Credentials authenticate the job, name each object by report and date, and upload with the .xlsx content type, descriptive metadata, checksum="md5" and if_generation_match=0 so published reports are never overwritten by accident. Large files go up as resumable uploads in chunk multiples of 256 KB, people outside Google Cloud get a signed URL valid for up to seven days, and a lifecycle rule removes old reports. Test all of it locally with fake-gcs-server by setting STORAGE_EMULATOR_HOST.

Frequently asked questions

How do I upload an Excel file to Google Cloud Storage with Python? Install google-cloud-storage, create storage.Client(), get the bucket, and call upload_from_filename on a blob with the path and the xlsx MIME type as content_type. Credentials come from Application Default Credentials.

How do I stop an upload from overwriting an existing file? Pass if_generation_match=0. The upload then succeeds only if no object with that name exists, and otherwise fails with 412 Precondition Failed.

How can I share a Cloud Storage file with someone without Google access? Generate a V4 signed URL for the object with an expiration of up to seven days. Anyone with the link can download the file until it expires.

How do I test Cloud Storage code locally? Run the fake-gcs-server emulator in Docker and set STORAGE_EMULATOR_HOST to its address. The client library then sends requests to the emulator with anonymous credentials.

More on publishing & sharing files

More on publishing & sharing files →