LangChain’s Google search tool after Custom Search shuts down
Replace GoogleSearchAPIWrapper before Google Custom Search ends on 1 January 2027: a tested LangChain tool that keeps title, link and snippet.

GoogleSearchAPIWrapper and the google_search tools built on it call the Google Custom Search JSON API, which Google has closed to new customers and ends on 1 January 2027. Nothing in the wrapper can route around that, so the fix is to swap the backend. Below is a 79-line LangChain tool, run on 19 October 2026, that keeps the same tool names and the same title, link and snippet fields, so existing chains and agent prompts keep working. A search costs 5 FastAF credits.
What exactly stops working in LangChain on 1 January 2027?
Google’s Custom Search JSON API overview says the API “is closed to new customers” and that existing customers “have until January 1, 2027 to transition to an alternative solution” (checked 19 October 2026). Its one named alternative, Vertex AI Search, is described as “a favorable alternative for searching up to 50 domains”, which is site search, not web search.
In langchain-google-community 5.1.1 (installed today in a fresh venv, with langchain 1.4.6 and langchain-core 1.6.12), GoogleSearchAPIWrapper builds a customsearch v1 client from GOOGLE_API_KEY and a GOOGLE_CSE_ID, and every call goes through cse().list(). The older copy in langchain_community is already marked deprecated. LangChain’s Google Search page still documents the wrapper with no shutdown note, and its tools index lists dozens of other search integrations. I could not test behavior after the shutdown date, so that part is Google’s statement, not my measurement.
For the wider picture see Google’s official Search API vs SERP APIs and web search for AI agents after Bing. This post is only the LangChain swap.
How do I replace GoogleSearchAPIWrapper with a drop-in tool?
The old wrapper has two calls that chains depend on: run(query), which joins snippets into one string, and results(query, num_results, search_params), which returns dicts with title, link and snippet, or [{"Result": "No good Google Search Result was found"}] when empty. The tools google_search and google_search_results_json sit on top. This module reproduces all of it, sync and async, on top of the /v1/serp endpoint:
import os, time, httpx
from langchain_core.tools import StructuredTool, ToolException
BASE = "https://api.fastaf.co/v1"
NOTHING = [{"Result": "No good Google Search Result was found"}]
COST = 5 # credits per search
class FastAFSearch:
"""Same surface as GoogleSearchAPIWrapper: run(), results(), arun(), aresults()."""
def __init__(self, k=10, max_credits=None, ttl=300, **defaults):
self.k, self.max_credits, self.ttl, self.defaults = k, max_credits, ttl, defaults
self.spent, self._cache = 0, {}
self._headers = {"Authorization": f"Bearer {os.environ['FASTAF_API_KEY']}"}
def _hit(self, key):
hit = self._cache.get(key)
return hit[1] if hit and time.time() - hit[0] < self.ttl else None
def _params(self, query, extra):
if self.max_credits is not None and self.spent + COST > self.max_credits:
raise ToolException(f"Search budget of {self.max_credits} credits is used up.")
self.spent += COST # reserve before the request, so parallel async calls cannot both pass
return {"query": query, **self.defaults, **(extra or {})}
def _parse(self, key, resp, num):
# An API error is never charged: give the reservation back. A 200 reports its real cost.
self.spent += (int(resp.headers["X-FastAF-Credits-Used"]) if resp.status_code == 200 else 0) - COST
if resp.status_code != 200:
raise ToolException(f"Search failed: HTTP {resp.status_code}")
out = [{"title": r["title"], "link": r["link"],
"snippet": r.get("inline_snippet") or r.get("snippet", "")}
for r in resp.json()["organic_results"][:min(num, 10)]]
self._cache[key] = (time.time(), out or NOTHING)
return out or NOTHING
def results(self, query, num_results, search_params=None):
key = (query, num_results, str(search_params))
if (hit := self._hit(key)) is not None:
return hit
resp = httpx.get(f"{BASE}/serp", params=self._params(query, search_params),
headers=self._headers, timeout=60)
return self._parse(key, resp, num_results)
async def aresults(self, query, num_results, search_params=None):
key = (query, num_results, str(search_params))
if (hit := self._hit(key)) is not None:
return hit
async with httpx.AsyncClient(timeout=60) as c:
resp = await c.get(f"{BASE}/serp", params=self._params(query, search_params),
headers=self._headers)
return self._parse(key, resp, num_results)
@staticmethod
def _join(rs):
return " ".join(r["snippet"] for r in rs if "snippet" in r) or NOTHING[0]["Result"]
def run(self, query: str) -> str:
return self._join(self.results(query, self.k))
async def arun(self, query: str) -> str:
return self._join(await self.aresults(query, self.k))
def fastaf_search_tools(api: FastAFSearch, num_results: int = 4):
"""Two tools with the names and descriptions of GoogleSearchRun / GoogleSearchResults."""
def as_json(query: str) -> str:
return str(api.results(query, num_results))
async def aas_json(query: str) -> str:
return str(await api.aresults(query, num_results))
desc = ("A wrapper around Google Search. Useful for when you need to answer questions "
"about current events. Input should be a search query.")
return [
StructuredTool.from_function(func=api.run, coroutine=api.arun, name="google_search",
description=desc, handle_tool_error=True),
StructuredTool.from_function(func=as_json, coroutine=aas_json,
name="google_search_results_json",
description=desc + " Output is a JSON array of the query results",
handle_tool_error=True),
]
Use it where you used the old pair:
from fastaf_search_tools import FastAFSearch, fastaf_search_tools
api = FastAFSearch(country="us", max_credits=50)
tools = fastaf_search_tools(api, num_results=5) # drop into create_agent(model, tools=tools)
One detail is measured, not assumed. Across 20 varied queries (192 results, 100 credits, 19 October 2026), 38 results (20%) carried an inline_snippet. In 36 of them the snippet field held only sitelink labels such as “More results from www.reddit.com”, and the real description was in inline_snippet. The module prefers inline_snippet, which is what the old snippet looked like. Every query returned 9 or 10 organic results.
What did I actually run, and what did it cost?
I had no LLM API key in this environment, so no real model is involved. I ran the tools directly (invoke and ainvoke), then inside a create_agent loop driven by a scripted fake chat model that replays three tool calls. That proves the wiring in the LangChain runtime, not model behavior.
Against https://api.fastaf.co/v1 on 19 October 2026:
| Run | Latency | Credits | Result |
|---|---|---|---|
google_search_results_json, sync |
3.20 s | 5 | 5 results, keys link, snippet, title |
| Same query again | 0.0002 s | 0 | served from the cache |
google_search, async |
4.74 s | 5 | 1,642 characters of joined snippets |
| Scripted agent, 3 tool calls, budget 10 | 5.8 s | 10 | third call refused with a message |
Earlier runs of the same code in production took 6.9 and 8.1 s per search, so plan for roughly 3 to 8 seconds, not milliseconds. In the 20-query run at concurrency 4, the median was 4.0 s and the maximum 12.4 s. Failed requests are not charged. The budget check was tightened after these runs (it now reserves the cost before the request) and re-run only against a stub: with max_credits=6, two parallel aresults calls let one through and refused the other.
How do the old parameters map to the new tool?
| GoogleSearchAPIWrapper | This tool |
|---|---|
GOOGLE_API_KEY + GOOGLE_CSE_ID |
FASTAF_API_KEY only, no engine ID |
k, num_results |
same names; capped at 10 per call, like Google’s num |
search_params={"gl": "de"} |
{"country": "de"} |
{"hl": "de"} |
{"lang": "de"} |
{"lr": ...}, {"cr": ...}, {"safe": "active"} |
same names |
{"start": 11} (1-based) |
{"page": 1} (zero-based; page 1 returned different results than page 0) |
siterestrict=True, siteSearch |
put site:example.com in the query (tested) |
exactTerms, excludeTerms |
quotes and -term in the query |
dateRestrict="w1" |
{"tbs": "qdr:w"}, a Google filter string (accepted; I did not verify the date filtering) |
image, thumbnail fields |
not returned |
searchType="image" |
not covered by this endpoint |
The behavioral difference is the bigger one. Custom Search queried an engine you configured. This returns what google.com returned for the query, so rankings will differ from your old results. Re-check any eval set that pinned to specific URLs. The results parameter of the API did not reduce the page size in my tests (results=3 and results=20 both returned 9), so the module slices client-side.
How do I cache and cap spending per run?
Both live in the module. ttl caches by query, count and parameters, so an agent that repeats a search pays once. max_credits reserves the 5 credits of a search before the request, swaps them for the X-FastAF-Credits-Used header of the response (an API error gives them back), and raises ToolException when the next search would not fit; with handle_tool_error=True the agent receives “Search budget of 10 credits is used up.” as a normal tool result and can reason about it, as in the table above. The wrapper keeps no state across processes, and the cache is in memory.
The balance on the account is the outer limit: an exhausted balance returns 402 and stops everything. Give an AI agent live web search with a hard spending cap covers the layers around this, and SERP APIs for RAG covers what to do with the results.
Can I use MCP instead of a custom tool?
Yes. FastAF runs a hosted MCP server with a search_web tool. I loaded it with langchain-mcp-adapters 0.3.2 (MultiServerMCPClient, streamable_http transport, Bearer header), got all 15 tools, and search_web returned 3 results for 5 credits in 4.9 s. Two catches: it returns url instead of link, and it passes snippet through unmodified, so you get the sitelink labels described above. For existing chains that read link, keep the custom tool. For a new agent that should also open pages, use MCP.
The short version
Google Custom Search ends on 1 January 2027 and GoogleSearchAPIWrapper stops with it. Copy the module, keep the tool names, check your ranking-sensitive tests, and set max_credits so a looping agent cannot run up a bill. A search is 5 credits, $1.33 to $2.50 per 1,000 depending on the pack, and the free tier is 500 credits, about 100 searches. Get a free key and try it against your own queries.