All posts
Guide·Jul 5, 2026·10 min read

Gmail API in Python: the practical guide

Use the Gmail API from Python: OAuth setup with google-api-python-client, sending, reading and parsing messages, attachments, and the pitfalls that bite in production.

Dvir Atias

Dvir Atias

Founder

This is a working, end-to-end guide to the Gmail API in Python: authenticate, send, read, search, and download attachments - with the production pitfalls called out as they come up. It assumes you've already created a Google Cloud project, enabled the Gmail API, and downloaded an OAuth client secret (if not, the setup section of our complete Gmail API guide walks through it).

Install the libraries

pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib

Authenticate (OAuth 2.0)

The Gmail API always acts as a user. The standard local flow opens a browser once, then persists a refresh token:

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
import os.path

SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]

def get_service():
    creds = None
    if os.path.exists("token.json"):
        creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
            creds = flow.run_local_server(port=0)
        with open("token.json", "w") as f:
            f.write(creds.to_json())
    return build("gmail", "v1", credentials=creds)

service = get_service()

Two pitfalls before you write more code:

  • Testing-mode refresh tokens expire after 7 days. While your OAuth consent screen is in Testing, token.json dies weekly with invalid_grant. Publish the app (or accept re-consent) before relying on it.
  • Scope changes invalidate tokens. If you change SCOPES, delete token.json and re-consent.

Send email

The API wants a base64url-encoded RFC 2822 message. Python's email stdlib builds it:

import base64
from email.message import EmailMessage

def send(service, to: str, subject: str, body: str):
    msg = EmailMessage()
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(body)

    encoded = base64.urlsafe_b64encode(msg.as_bytes()).decode()
    return service.users().messages().send(
        userId="me", body={"raw": encoded}
    ).execute()

send(service, "dest@example.com", "Hello", "Sent from Python via the Gmail API.")

For HTML, add msg.add_alternative("<p>Hello</p>", subtype="html"). For attachments, msg.add_attachment(data, maintype="application", subtype="pdf", filename="report.pdf"). Threading replies means setting In-Reply-To and References headers yourself and passing threadId in the body.

Read and search messages

def unread_from(service, sender: str):
    resp = service.users().messages().list(
        userId="me", q=f"is:unread from:{sender}", maxResults=10
    ).execute()
    for meta in resp.get("messages", []):
        msg = service.users().messages().get(
            userId="me", id=meta["id"], format="full"
        ).execute()
        yield msg

The q parameter accepts full Gmail search syntax (newer_than:2d, has:attachment, subject:"invoice"). But the returned payload is a raw MIME tree; extracting the text body takes real work:

import base64

def extract_text(payload) -> str:
    if payload.get("mimeType") == "text/plain" and payload["body"].get("data"):
        return base64.urlsafe_b64decode(payload["body"]["data"]).decode(errors="replace")
    for part in payload.get("parts", []) or []:
        text = extract_text(part)
        if text:
            return text
    return ""

This handles the common multipart/alternative case; nested multipart/mixed with inline images and forwarded messages will exercise the recursion. Headers arrive as a list of {"name": ..., "value": ...} dicts you filter yourself.

Download attachments

Attachments above ~a few KB aren't inline - they need a third API call:

def save_attachments(service, msg):
    for part in msg["payload"].get("parts", []) or []:
        filename = part.get("filename")
        att_id = part.get("body", {}).get("attachmentId")
        if filename and att_id:
            att = service.users().messages().attachments().get(
                userId="me", messageId=msg["id"], id=att_id
            ).execute()
            with open(filename, "wb") as f:
                f.write(base64.urlsafe_b64decode(att["data"]))

Poll or push?

Polling messages.list every few seconds burns quota (5 units per list, 5 per get, 250 units/user/second cap). The push alternative - users.watch plus a Cloud Pub/Sub topic plus history diffing plus 7-day watch renewal - is covered in our Gmail webhook guide.

Rate limits and errors worth handling

from googleapiclient.errors import HttpError
import time, random

def with_backoff(fn, retries=5):
    for attempt in range(retries):
        try:
            return fn()
        except HttpError as e:
            if e.resp.status in (429, 500, 503):
                time.sleep((2 ** attempt) + random.random())
                continue
            raise

429 rateLimitExceeded, 403 userRateLimitExceeded, and transient 5xxs are all normal at volume. Remember the account-level sending caps too (roughly 500 recipients/day consumer, 2,000 Workspace) - the API does not lift them.

The honest alternative for bots and agents

Everything above is the right pain to accept when the Gmail account belongs to a user of your product. If you're doing this so your script, service, or AI agent "has an email address," you are paying OAuth + MIME + Pub/Sub + verification-review costs for a mailbox Google built for humans.

The equivalent in an API built for programmatic inboxes:

from agenticemail import AgenticEmail

client = AgenticEmail("am_...")
inbox = client.inboxes.create(username="agent")
client.messages.send(inbox["id"], to=["dest@example.com"],
                     subject="Hello", text="One call.")
# inbound mail arrives as parsed JSON via webhook/WebSocket - no MIME tree

That's the AgenticEmail Python SDK (pip install agenticemail). Real inboxes on your own domain, parsed inbound JSON, signed webhooks, and no consent screens. See the quickstart or the broader comparison in the complete Gmail API guide.

Talk to a real person