How to search Google Patents from Python without an API key

Google Patents has no official API. The paid route is SerpAPI, and the free official ones each cover a single office, such as USPTO's PatentsView for US patents. But the Google Patents website loads its results from a JSON endpoint, and that endpoint answers plain HTTP requests without a key. The patent search in the Paper workspace of the osFoundry desktop app uses it from Rust. Below is the same request in Python with only the standard library, the filters it accepts, what the response looks like, and what happens after five queries in half a minute.
The request is a query string inside a query string
The endpoint is https://patents.google.com/xhr/query. It takes one real parameter, url, whose value is itself an encoded query string: the same one you see after the ? when you search on the website. So the search terms are encoded twice.
import html, json, re, urllib.error, urllib.parse, urllib.request UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" def search(query, **filters): inner = urllib.parse.urlencode({"q": f"({query})", **filters}) url = "https://patents.google.com/xhr/query?" + urllib.parse.urlencode({"url": inner, "exp": ""}) req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as r: status, text = r.status, r.read().decode() except urllib.error.HTTPError as e: status, text = e.code, e.read().decode(errors="replace") if status in (429, 503) or text.lstrip().startswith("<"): raise RuntimeError(f"rate limited ({status})") return json.loads(text)["results"] The parentheses around the query are what the website sends too. Without a browser user agent the endpoint is more likely to refuse, so the app sends one, and so does this function. The last check before parsing is explained further down.
Filters
The filters go into the inner query string, next to q. These are the ones the app passes through, with the result counts for water bottle glows reminder to drink:
| inner parameter | example | results |
|---|---|---|
| (none) | 2,757 | |
status | GRANT | 685 |
status | APPLICATION | 1,739 |
country | JP | 157 |
before / after | priority:20190101 | not measured |
sort | new or old | not measured |
num / page | 20 / 1 | not measured |
results = search("water bottle glows reminder to drink", status="GRANT") print(results["total_num_results"]) # 685 Two things in those numbers are easy to get wrong. First, 685 granted plus 1,739 applications is 2,424, not 2,757, so 333 documents are neither and the two filters don't split the set. Second, the count is documents, not inventions. The same invention usually appears once as a published application and again as a grant. "Smart container with interactive, colored lights" is in the application list as US20220218137A1 and in the granted list as US12082729B2, with the same title and the same priority date, 2019-04-30. If you want to count inventions, group by priority date and title, or by family, before counting.
The date filters compare against the priority date, which is the date that matters for prior art. The app only lets an 8-digit date through, and only the exact country, status and sort values above, because these strings are spliced into a URL:
/// Filter values are spliced into upstream query strings, so only the exact /// shapes each upstream documents are passed through. fn filter_params(filters: &PatentFilters) -> Vec<(&'static str, String)> { Reading the response
The hits are nested two levels down: results.cluster[].result[].patent. Each patent has title, snippet, priority_date, filing_date, grant_date, publication_date, inventor, assignee, publication_number, language, a thumbnail path and a list of figures.
The text fields are HTML-escaped, not plain text. Titles come with a leading space (' Liquid intake management system'), apostrophes arrive as ', and snippets end in …. The app's Rust also strips tags, because Google can wrap matched words in <b>. One regex and html.unescape cover all of it:
def plain(v): return " ".join(html.unescape(re.sub(r"<[^>]*>", "", v or "")).split()) def hits(results): for cluster in results["cluster"]: for hit in cluster["result"]: yield hit["patent"] for p in hits(search("water bottle glows reminder to drink", status="GRANT")): print(p["publication_number"], p["priority_date"], plain(p["title"])) US12564280B2 2015-03-09 Wireless drink container for monitoring hydration US12082729B2 2019-04-30 Smart container with interactive, colored lights JP6067764B2 2013-04-03 Liquid intake management system RU2707893C2 2014-02-28 Personal steam generator for electronic cigarette US11581099B1 2017-05-24 Automated home system for senior care ... The Japanese and Russian patents come back with English titles because Google machine-translates them. The assignee is not translated: JP6067764B2's assignee is still in Japanese. The fourth result is a steam generator for electronic cigarettes, which shows how loose the matching is. Plain words are not an exact phrase search, so expect to read the top results and refine the words, not to take the count at face value.
Images are paths relative to https://patentimages.storage.googleapis.com/. The second figure of US12082729B2 is at e6/88/67/673b4fcbbe5404/US12082729-20240910-D00001.png, and that host serves it to a plain request:
The response also has total_num_pages and a summary block that counts assignees, inventors and CPC classes by year. Both stop at 1,000 documents: the summary's "Total" was 1,000 for a search with 2,757 results, and total_num_pages was 100 at 10 results a page. Without num, a page has 10 hits. The app asks for 20 and clamps the value between 10 and 100.
What happens when you send too many
A test script queued six queries with a 4-second pause between them. The first four answered in 2.8 to 4.5 seconds each. The fifth, about 30 seconds after the first, came back as HTTP 500 with an HTML page. A retry a minute later got HTTP 503 and Google's "Sorry..." page, and so did every retry after that, the last one almost 14 minutes after the first refusal. The sixth query never ran.
So the block is not a JSON error you can catch with a status code check alone. It can be a 503 or a 429, and here the first one was even a 500, but in every case the body was an HTML page. That is why the function above checks whether the body starts with < before parsing. The app does the same, and when the user has a SerpAPI key, it sends the same query through SerpAPI instead of failing:
// The block page is HTML served as 503 (sometimes 429). if status.as_u16() == 429 || status.as_u16() == 503 || text.trim_start().starts_with('<') { return Err(GoogleError::Limited); } For a script, the practical rules are: cache every response by its inner query string, send one query at a time, and stop at the first HTML response instead of retrying in a loop. The endpoint is undocumented, so its shape can change. For anything regular or large, an official source (PatentsView for the US, EPO's Open Patent Services for Europe, the Japan Patent Office's patent information API for Japan) or SerpAPI is the safer choice.
The desktop app that makes this request is free from the osFoundry download page.