How to Scrape Google Flights With Python in 2026

Flight data and Python code displayed for scraping Google Flights with rotating proxies in 2026
TL;DR

Google Flights is not your average scraping target. The page is almost entirely JavaScript-rendered, prices change based on the country your IP is from (not just URL parameters), and silent failures are more common than actual blocks. Here are the three approaches that actually work in 2026.

  • For quick, one-off lookups: the fast-flights Python library (pip install fast-flights) skips the browser entirely and hits Google's protobuf-encoded endpoint directly. It's fast, typed, and returns structured data in seconds. Doesn't scale to high-frequency use without a proxy though.
  • For more robust data extraction: Playwright with rotating residential proxies. Playwright renders the full JavaScript-heavy page. Residential proxies provide geo-accurate pricing per target market. Note that automated access violates Google's Terms of Service, so assess legal risk for your specific use case before proceeding.
  • Country matters more than you think. A JFK → LHR search from a US proxy IP will return different prices than the same search from a UK proxy IP. URL parameters alone don't fix this. Your proxy country needs to match your target market.
  • Silent failures are the real problem. Google Flights doesn't always block you outright. Sometimes it just returns a page with no flight data in it. Always validate that your scraper actually got results, not just a 200 status code.
  • When to skip DIY scraping entirely: If you're building a commercial booking tool or need guaranteed real-time accuracy, use the Amadeus API or a licensed SERP API. The maintenance cost of a DIY Google Flights scraper at scale is real.
  • TorchProxies Plan X covers all the target markets in this guide: US, UK, India, Japan, South Korea, Germany, Canada, Indonesia, Netherlands, and Hong Kong. 120M+ IPs, city-level targeting, no rate limits, starting from $5/GB.

Scraping Google Flights is one of those tasks that looks simple until you actually try it. The first time I set this up, I got a seemingly working scraper in an hour, returning 200s, parsing HTML, printing results. Then I checked the actual data and found prices from three different countries for the same route. That's when I learned Google Flights personalizes results based on your IP's location, not just URL parameters. This guide covers the three working approaches with real Python code, why your proxy's country changes the prices you see, and how to detect silent failures before they corrupt your data.

From the Field
When I first set this up I ran into a weird one. My scraper was technically working, price data was coming through, but the numbers were way off compared to what I saw when I opened Google Flights in a browser. I spent two days debugging the parsing logic before I realized the issue had nothing to do with the code. My proxy was routing through a Netherlands IP and Google Flights was returning prices in EUR, personalized for a European buyer, for a route I was researching from the US perspective. The proxy country was right in the middle of everything and I hadn't even thought to check it. Now I always log the currency and the proxy country alongside every scraped result. Sounds obvious in retrospect.

Why Google Flights Requires a Different Approach

Before writing a single line of code, it's worth understanding what makes Google Flights harder to scrape than a regular webpage. Most guides skip this part and it's exactly why most scrapers built from those guides break quietly.

Google Flights is a JavaScript-rendered single-page application. When you send a plain HTTP request to https://www.google.com/travel/flights, the response you get back is essentially an empty shell. The actual flight data, prices, airline names, departure times, none of it is in that initial HTML. It all loads after JavaScript runs. This is why requests and BeautifulSoup alone are a dead end. You get HTML but not the HTML you need.

0
Flight results in a plain requests.get() response
tfs
The protobuf URL parameter that encodes your entire search query
3
Working Python approaches in 2026 that don't require a Google account
10
Target markets where IP country changes the price you see

The second thing that makes Google Flights unique is the URL structure. Unlike Google Search which uses plain query strings like ?q=flights+new+york+to+london, Google Flights encodes your entire search into a single parameter called tfs. It looks like this:

A Real Google Flights Search URL
https://www.google.com/travel/flights/search?tfs=CBwQAhoeEgoyMDI2LTA2LTAxagcIARIDSkZLcgcIARIDTEhS&hl=en
📜
The tfs Parameter Explained
The tfs URL parameter is a Base64-encoded Protocol Buffer (protobuf) string. Protobuf is Google's own binary serialization format, the same one they use internally across their systems. It encodes your entire flight search: departure airport, arrival airport, dates, trip type (one-way or round-trip), seat class, and passenger count. Decoding a tfs string reveals your search in readable form. The fast-flights library handles generating these strings for you so you don't need to understand protobuf to use it.

The third thing, and the one that trips up the most scrapers, is location-based price personalization. Google Flights doesn't just use the gl URL parameter to determine which prices to show you. It also uses your IP address's geographic location. A search for JFK to LHR from a US residential IP will return different prices than the exact same search from a UK residential IP, even with identical URL parameters. The price difference can be meaningful. This matters especially if you're doing market research across multiple countries. Think of it like this: Google Flights is treating each IP origin as a different customer from a different market, because that's basically what it is.


What Data You Can Actually Extract

Google Flights is genuinely data-rich once you get through the rendering layer. Here's what's available and how reliably you can get each field.

Data Field What It Includes Reliability Notes
Flight Price Ticket price in local currency for the IP's location High Always log the currency alongside the price. The same number in USD vs. EUR means very different things.
Airline Name Operating carrier's name and IATA code High Codeshare flights sometimes show multiple airline names. Parse carefully.
Flight Number Specific flight designation (e.g., BA117) Medium Not always visible in the initial results view. May require expanding a result.
Departure / Arrival Times Scheduled departure and arrival, including timezone High Always store the timezone. A 10:00 departure from JFK is not the same as 10:00 from LHR.
Duration Total flight time including layovers High Format varies: "13 hr 45 min" or "13h 45m" depending on language setting.
Stops / Layovers Number of stops and intermediate airport codes High Direct flights show "Nonstop." Layover airport IATA codes are in the expanded view.
Carbon Emissions CO2 estimate in grams per passenger, vs. route average Medium Available for most routes. Useful for sustainability-focused tools or research.
Price Trend Label "Typical," "Low," or "High" relative to historical prices Medium Google shows this label near the price. Useful for price tracker tools.
Airport Codes Departure and arrival IATA codes High Three-letter codes: JFK, LHR, NRT, ICN, DEL, HKG, AMS, FRA, CGK, YYZ.

One field I'm intentionally not covering in depth in this guide is booking tokens. Google embeds booking URLs that deep-link into airline or OTA booking pages. Extracting those is technically possible, but they expire quickly and the legal picture around redirecting users through those links for commercial purposes gets more complicated. That deserves its own guide.


Setup: What You Need Before You Start

Quick environment check before diving into the code. Python 3.9 or later is required for both approaches below. Run python --version to confirm. Then install the libraries for whichever approach you're going with.

For Approach 1: fast-flights

Install fast-flights
pip install fast-flights

That's all you need for Approach 1. No browser installation, no extra dependencies. The fast-flights library ships with everything it needs to build and decode protobuf URL parameters and parse the JSON data embedded in Google Flights' server-side rendered HTML.

For Approach 2: Playwright + Proxy

Install Playwright + BeautifulSoup
pip install playwright beautifulsoup4 httpx
playwright install chromium
IATA Airport Codes You'll Need
Google Flights uses three-letter IATA airport codes for all searches. Here are the codes for the target markets covered in this guide: US: JFK (New York), LAX (Los Angeles), ORD (Chicago) · UK: LHR (London Heathrow), LGW (Gatwick) · India: DEL (Delhi), BOM (Mumbai) · Japan: NRT (Tokyo Narita), HND (Haneda) · South Korea: ICN (Seoul Incheon) · Germany: FRA (Frankfurt), MUC (Munich) · Canada: YYZ (Toronto), YVR (Vancouver) · Indonesia: CGK (Jakarta) · Netherlands: AMS (Amsterdam) · Hong Kong: HKG. Find any code on IATA's official code search.

Approach 1: fast-flights Library (Quick Lookups, No Browser)

The fast-flights library is genuinely clever. Instead of launching a browser and waiting for JavaScript to render, it generates the correct protobuf-encoded tfs URL parameter, fetches the server-side rendered page directly via HTTP, and extracts flight data from a JSON object embedded in a <script> tag. No Playwright, no browser overhead, no Chromium binaries to manage.

This is what I'd reach for first for any lookup that doesn't need to run at high frequency. It's fast and the API is clean.

Basic One-Way Flight Lookup

fast-flights: One-Way Search (JFK to LHR)
from fast_flights import FlightData, Passengers, Result, get_flights

# Define your search
result: Result = get_flights(
    flight_data=[
        FlightData(
            date="2026-06-15",       # YYYY-MM-DD format
            from_airport="JFK",    # IATA departure code
            to_airport="LHR",      # IATA arrival code
        )
    ],
    trip="one-way",
    seat="economy",           # economy, premium-economy, business, first
    passengers=Passengers(
        adults=1,
        children=0,
        infants_in_seat=0,
        infants_on_lap=0,
    ),
    fetch_mode="fallback",    # Use fallback if primary parse fails
)

# Check if the price is low, typical, or high vs. historical data
print(f"Price trend: {result.current_price}")

# Loop through flight results
for flight in result.flights:
    print(f"Airline:    {flight.name}")
    print(f"Departure:  {flight.departure}")
    print(f"Arrival:    {flight.arrival}")
    print(f"Duration:   {flight.duration}")
    print(f"Stops:      {flight.stops}")
    print(f"Is best:    {flight.is_best}")
    print("---")

Round-Trip Search

fast-flights: Round-Trip Search
from fast_flights import FlightData, Passengers, Result, get_flights

result: Result = get_flights(
    flight_data=[
        FlightData(
            date="2026-06-15",
            from_airport="JFK",
            to_airport="NRT",    # Tokyo Narita
        ),
        FlightData(
            date="2026-06-29",    # Return leg: add a second FlightData
            from_airport="NRT",
            to_airport="JFK",
        ),
    ],
    trip="round-trip",
    seat="economy",
    passengers=Passengers(adults=1, children=0, infants_in_seat=0, infants_on_lap=0),
    fetch_mode="fallback",
)

print(f"Found {len(result.flights)} results")
print(f"Price trend: {result.current_price}")

for f in result.flights[:5]:  # First 5 results
    print(f"{f.name} | {f.departure} -> {f.arrival} | {f.duration} | {f.stops} stops | Best: {f.is_best}")
fast-flights Limitations to Know
The library is excellent for low-to-medium frequency lookups, but it has limits you should know before relying on it. As of v3.0 (the current release), multi-city itineraries are supported via trip="multi-city". At high request volumes without a proxy, you will hit Google's rate limits. The library does not handle proxy rotation internally. Some small regional airports return empty results because Google computes their data on-demand and the library's protobuf encoding may not trigger that computation.

What I'd use this for: building a personal price alert script that runs twice a day, doing one-off route research, or prototyping before deciding whether to build something bigger. What I wouldn't use it for: monitoring hundreds of routes hourly, or any use case where a failed request silently returning no data is a problem. More on that in the mistakes section.


Approach 2: Playwright + Rotating Residential Proxies (Scalable)

When fast-flights is not the right fit, Playwright is a more capable option. It launches a real Chromium browser and handles all the JavaScript rendering Google Flights requires. With a residential proxy attached, requests originate from a real residential IP address, which also gives you geo-accurate pricing for each target market.

The setup here uses TorchProxies rotating residential proxies. One new browser context per request ensures each search gets a fresh IP assignment. This is the configuration that actually holds up under sustained use.

Installing and Configuring the Scraper

Playwright Google Flights Scraper with TorchProxies
import asyncio
import json
import random
from datetime import datetime, timezone
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError

# TorchProxies rotating residential credentials
PROXY_HOST = "residential.torchproxies.com"
PROXY_PORT = "31112"
PROXY_USER = "your_username"
PROXY_PASS = "your_password"

COUNTRY_TIMEZONES = {
    "us": "America/New_York",
    "gb": "Europe/London",
    "de": "Europe/Berlin",
    "jp": "Asia/Tokyo",
    "kr": "Asia/Seoul",
    "in": "Asia/Kolkata",
    "id": "Asia/Jakarta",
    "nl": "Europe/Amsterdam",
    "hk": "Asia/Hong_Kong",
    "ca": "America/Toronto",
}

def is_blocked_or_empty(html: str, flights: list) -> bool:
    block_signals = [
        "Our systems have detected unusual traffic",
        "g-recaptcha",
        "sorry.google.com",
    ]
    if any(s in html for s in block_signals):
        return True
    return len(flights) == 0

async def parse_flights_from_dom(page) -> list:
    """
    Prefer ARIA/text-based locators over brittle class names.
    This still may need maintenance when Google changes UI.
    """
    flights = []
    cards = page.locator("li").filter(has_text="Select flight")
    count = await cards.count()

    for i in range(min(count, 20)):
        card = cards.nth(i)
        try:
            text = await card.inner_text(timeout=1000)
            flights.append({
                "raw_text": text,
                "scraped_at": datetime.now(timezone.utc).isoformat(),
            })
        except Exception:
            continue

    return flights

async def scrape_google_flights(
    from_airport: str,
    to_airport: str,
    departure_date_iso: str,  # YYYY-MM-DD
    country_code: str = "us",
    lang: str = "en",
    max_retries: int = 3,
) -> list:
    """
    Note: Google Flights UI is dynamic and localized.
    Validate selectors in your target locales before scaling.
    """
    results = []
    url = f"https://www.google.com/travel/flights?hl={lang}&gl={country_code}"

    async with async_playwright() as pw:
        browser = await pw.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-dev-shm-usage"],
        )

        for attempt in range(1, max_retries + 1):
            context = await browser.new_context(
                proxy={
                    "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
                    "username": PROXY_USER,
                    "password": PROXY_PASS,
                },
                locale=f"{lang}-{country_code.upper()}",
                timezone_id=COUNTRY_TIMEZONES.get(country_code, "America/New_York"),
            )
            await context.add_init_script(
                "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
            )
            page = await context.new_page()

            try:
                await page.goto(url, wait_until="domcontentloaded", timeout=45000)

                # Use role/name locators (safer than fragile CSS class selectors)
                await page.get_by_role("button", name="Where from?").click(timeout=5000)
                await page.keyboard.press("Control+A")
                await page.keyboard.type(from_airport, delay=60)
                await page.keyboard.press("Enter")

                await page.get_by_role("button", name="Where to?").click(timeout=5000)
                await page.keyboard.type(to_airport, delay=60)
                await page.keyboard.press("Enter")

                # ISO date (YYYY-MM-DD) is locale-independent
                await page.get_by_role("button", name="Departure").click(timeout=5000)
                await page.keyboard.type(departure_date_iso, delay=50)
                await page.keyboard.press("Enter")

                await page.wait_for_timeout(random.randint(2500, 4500))

                html = await page.content()
                flights = await parse_flights_from_dom(page)

                if is_blocked_or_empty(html, flights):
                    await asyncio.sleep(random.uniform(3, 7))
                    continue

                results = flights
                break

            except PlaywrightTimeoutError:
                await asyncio.sleep(random.uniform(2, 5))
            except Exception:
                await asyncio.sleep(random.uniform(2, 5))
            finally:
                await context.close()

        await browser.close()

    return results

if __name__ == "__main__":
    data = asyncio.run(
        scrape_google_flights(
            from_airport="JFK",
            to_airport="LHR",
            departure_date_iso="2026-06-15",
            country_code="us",
            lang="en",
        )
    )
    print(json.dumps(data[:3], indent=2))
    print(f"Total parsed cards: {len(data)}")

A few things worth flagging in this code. The is_blocked_or_empty() function is the most important part. Google Flights will sometimes return a valid-looking page with no actual flight data in it when it detects bot-like behavior. Without this check, your scraper would report "0 results" and move on, which corrupts your dataset silently. Always validate that you got actual data back, not just a successful HTTP response.

Timezone matching in the browser context matters for accurate results. If your TorchProxies IP is from Japan but your browser context reports a New York timezone, Google Flights may return mismatched pricing or localized content. The COUNTRY_TIMEZONES mapping keeps the browser locale and timezone consistent with the proxy country.


Geo-Targeting: Why Your Proxy Country Changes the Price You See

This is the section most other guides skip entirely. It's also the part that matters most if you're doing any kind of multi-market research.

Flight prices on Google Flights are not static values that you simply retrieve. They are personalized based on the geographic origin of the request. This happens for two reasons. First, airlines and travel booking systems practice market-based dynamic pricing. The same seat on the same flight is often listed at different prices in different regional markets based on local demand, purchasing power, and competitive landscape. Second, Google itself personalizes the displayed prices and currency based on the IP's location, separate from the gl URL parameter.

What this means in practice: if you're researching flight prices as a US-based traveler would see them, you need a US residential proxy IP, not just ?gl=us in the URL. Both are needed. The parameter tells Google which regional results to prioritize. The proxy IP tells Google (and the underlying booking systems) which market's pricing to display.

Target Market Country Code Language (hl) Proxy Country Needed Notes
United States us en US residential IP Prices in USD. City-level targeting useful for US-originating searches.
United Kingdom gb en UK residential IP Prices in GBP. Note: the country code is gb, not uk, in most URL contexts.
Canada ca en or fr CA residential IP Prices in CAD. Use hl=fr for Quebec-targeted research.
Hong Kong hk zh-TW HK residential IP Traditional Chinese. Different results from mainland China. Prices in HKD.
India in en IN residential IP Prices in INR. Google holds over 97% search share in India as of 2025.
Germany de de DE residential IP Prices in EUR. German language results show meaningfully different route priorities.
Indonesia id id ID residential IP Prices in IDR. Regional Asian carriers prominently featured.
Netherlands nl nl NL residential IP Prices in EUR. KLM routes feature prominently from AMS-origin searches.
South Korea kr ko KR residential IP Prices in KRW. Korean Air and Asiana prominently featured from ICN.
Japan jp ja JP residential IP Prices in JPY. ANA and JAL featured from NRT/HND searches.

The key takeaway from this table: both the gl URL parameter and a matching residential proxy IP from the target country are needed for geo-accurate results. One without the other will give you mismatched pricing.

Plan X Hybrid Proxy
10 countries. 1 proxy plan. City-level targeting.
All ten target markets from this guide covered under one subscription. No rate limits, rotating residential IPs, free trial with no credit card. From $5/GB.
Try Plan X Free

Building a Basic Flight Price Tracker

Most guides stop at "here's how to get data once." The actually useful version of this tool is one that runs on a schedule, saves historical prices, and alerts you when something drops. Here's a minimal working version using fast-flights plus a simple JSON file as the data store.

Flight Price Tracker: Schedule + History + Drop Alert
import json
import os
import re
from datetime import datetime
from fast_flights import FlightData, Passengers, get_flights

HISTORY_FILE = "flight_prices.json"
DROP_THRESHOLD_PCT = 10.0  # Alert if price drops >= 10%

def load_history() -> dict:
    if os.path.exists(HISTORY_FILE):
        with open(HISTORY_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return {}

def save_history(history: dict) -> None:
    with open(HISTORY_FILE, "w", encoding="utf-8") as f:
        json.dump(history, f, indent=2, ensure_ascii=False)

def parse_price(value) -> float:
    """
    Handles ints/floats or strings like '$823', 'EUR 1,245', 'JPY 42,000'
    """
    if value is None:
        return 0.0
    if isinstance(value, (int, float)):
        return float(value)
    s = str(value)
    cleaned = re.sub(r"[^\d.,]", "", s).replace(",", "")
    try:
        return float(cleaned) if cleaned else 0.0
    except ValueError:
        return 0.0

def check_route(from_airport: str, to_airport: str, departure_date: str, route_key=None):
    key = route_key or f"{from_airport}-{to_airport}-{departure_date}"

    try:
        result = get_flights(
            flight_data=[
                FlightData(
                    date=departure_date,
                    from_airport=from_airport,
                    to_airport=to_airport,
                )
            ],
            trip="one-way",
            seat="economy",
            passengers=Passengers(adults=1, children=0, infants_in_seat=0, infants_on_lap=0),
            fetch_mode="fallback",
        )
    except Exception as e:
        print(f"Error fetching {key}: {e}")
        return

    if not result.flights:
        print(f"{key}: No results (possible block or no inventory).")
        return

    # Filter to flights with a parseable numeric price
    priced = []
    for f in result.flights:
        p = parse_price(getattr(f, "price", None))
        if p > 0:
            priced.append((f, p))

    if not priced:
        print(f"{key}: Flights returned but no parseable numeric price.")
        return

    best_flight, best_price = min(priced, key=lambda x: x[1])
    timestamp = datetime.now().isoformat()

    entry = {
        "timestamp": timestamp,
        "airline": best_flight.name,
        "departure": best_flight.departure,
        "arrival": best_flight.arrival,
        "duration": best_flight.duration,
        "stops": best_flight.stops,
        "price": best_price,
        "price_raw": str(getattr(best_flight, "price", "")),
        "price_trend": getattr(result, "current_price", None),
        "is_best": bool(getattr(best_flight, "is_best", False)),
    }

    history = load_history()
    history.setdefault(key, [])
    previous_price = history[key][-1]["price"] if history[key] else None

    history[key].append(entry)
    save_history(history)

    print(f"{key}: {best_flight.name} | {best_price:.2f} | trend={entry['price_trend']}")

    # Real numeric drop detection
    if previous_price and previous_price > 0:
        drop_pct = ((previous_price - best_price) / previous_price) * 100
        if drop_pct >= DROP_THRESHOLD_PCT:
            print(
                f"ALERT: Price dropped {drop_pct:.1f}% "
                f"({previous_price:.2f} -> {best_price:.2f}) for {key}"
            )

if __name__ == "__main__":
    routes = [
        ("JFK", "LHR", "2026-06-15"),
        ("LHR", "NRT", "2026-07-01"),
        ("AMS", "HKG", "2026-08-10"),
    ]
    for dep, arr, date in routes:
        check_route(dep, arr, date)

To make this run on a schedule, set up a cron job. On a Linux server or Mac:

Cron Job: Run the Tracker Every 6 Hours
# Add to crontab (run: crontab -e)
0 */6 * * * /usr/bin/python3 /your/path/flight_tracker.py >> /your/path/tracker.log 2>&1

Note on pricing data: depending on your installed version and target route, fast-flights may return a trend label ("low," "typical," "high") rather than a numeric price. The Block 7 code above handles this by filtering for flights where a parseable numeric price is available. If no numeric price is returned for your route, the Playwright approach will give you the actual displayed fare.


Mistakes I Made (And You Will Too)

These are the ones that cost me the most time. Listed in order of how much they stung.

Mistake 01
Not Validating That Results Exist
The scraper returned HTML, the parser ran, the loop executed zero iterations, and the empty array was saved as "no flights available." For six hours. Always check len(flights) > 0 before treating a result as valid data.
Mistake 02
Ignoring the Proxy's Country
I was targeting US routes with a European proxy because that's what was configured for a different project. Everything parsed fine. The prices were just wrong. Always log the proxy country alongside your data.
Mistake 03
Hardcoding One CSS Selector
Google Flights runs A/B tests constantly. The .pIav2d container class has changed before and will change again. Always build selector fallback chains, not single-point dependencies.
Mistake 04
Reusing One Browser Context
With rotating proxies, reusing the same browser context across requests means you keep the same proxy IP even though the rotation should give you a new one. Create a new context for each search request.
Mistake 05
Not Storing the Currency
A price of "823" without knowing if it's USD, GBP, or JPY is genuinely meaningless data. Store the currency symbol or code alongside every price value. Always.
Mistake 06
Using a Datacenter Proxy
Google Flights distinguishes between datacenter and residential IP origins. Datacenter proxies get blocked or served degraded results significantly faster than residential proxies. The cost difference is worth it for this target.

When to Skip DIY Scraping Entirely

This section is the one I actually want people to read before deciding to build a scraper, because sometimes the right answer is to not build it at all.

You probably do not need to build your own Google Flights scraper if:

  • You're checking prices for your own travel planning. Just use Google Flights directly in your browser. This guide is overkill for personal use.
  • You're building a commercial OTA or booking platform. Use Amadeus API. It is a licensed data feed with SLAs, legal clearance, and support, though production usage is pay-as-you-go and costs can add up at scale. Note: Skyscanner's API requires partner application and approval and is not available for general developer use. A DIY Google Flights scraper at commercial booking scale is a maintenance concern and carries real legal risk under Google's Terms of Service.
  • You need guaranteed real-time booking availability. Google Flights data is great for prices and schedules but it's not the authoritative source for seat inventory at the time of booking. Airlines' own APIs are the truth source for that.
  • Your team doesn't have bandwidth to maintain a scraper. Google Flights changes its HTML structure regularly. Someone needs to own that maintenance. If that person doesn't exist on your team, budget for a SERP API instead.

If your use case is mainly checking one route for personal travel, you genuinely do not need this. Google Flights works fine for that. The setup in this guide makes sense when you're querying dozens of routes across multiple markets on a recurring schedule.

Honest take: I haven't personally tested the Playwright approach against every one of the 10 target markets under high-frequency conditions. I know it works for US and UK. For markets like Indonesia and South Korea specifically, the CSS selectors for the localized page layouts may behave differently. Start with low volume and validate your parser output for each market before scaling up.

Residential Proxies for Flight Data Research

120M+ IPs across 195 countries. All 10 target markets in this guide. Rotating residential, city-level targeting, no rate limits. Drop into your Playwright config in two lines.

Start Free Trial →

✓ 195+ countries ✓ No rate limits ✓ No credit card for trial ✓ From $5/GB

The Bottom Line

Google Flights is scrapable in 2026. It's not easy, but it's doable with the right setup. The three approaches here cover different points on the speed/reliability/maintenance tradeoff curve: fast-flights for quick lookups without a browser, Playwright with residential proxies for reliable production scraping, and SERP APIs when the maintenance overhead isn't worth it at scale.

The one thing most guides miss is the country-based pricing piece. Get that wrong and your data is accurate-looking but meaningfully wrong. Your proxy country needs to match the market you're researching. That's the most important detail in this entire guide.

Quick Reference Summary
Use fast-flights for quick lookups No browser needed. Fast. Good for one-off scripts and price trackers. Limited numeric pricing.
Use Playwright + rotating residential proxy for scale Full JS rendering. Geo-accurate prices per market. New context per request for IP rotation.
Match proxy country to target market URL parameters alone are not enough. JP proxy for Japanese prices. US proxy for US prices.
Always validate results, not just status codes Google Flights can return empty data with a 200 response. Check len(flights) > 0.
Store currency alongside every price A number without a currency is meaningless data. Log it every time.
Know when to use an API instead Commercial booking platforms, guaranteed availability data, no maintenance budget: use Amadeus.

FAQs

The legal status of scraping Google Flights is genuinely complex and evolving. Google's Terms of Service explicitly prohibit automated access, which creates civil liability risk regardless of whether criminal law applies. Circumventing technical protections may implicate DMCA Section 1201. For personal research or internal tooling at low volume, risk is lower, but no scraping of Google services is risk-free. For any commercial use of flight data, use a licensed API such as Amadeus. This is not legal advice. Consult a qualified attorney for your specific situation.
Two main approaches work in 2026. For quick lookups: pip install fast-flights then use the get_flights() function with IATA codes and dates. No browser needed. For more robust data extraction: Playwright with a rotating residential proxy handles JavaScript rendering and returns geo-accurate prices per target market. For commercial use where legal compliance matters, use a licensed API such as Amadeus rather than scraping.
Two reasons. First, repeated requests from a single IP trigger rate limiting and CAPTCHAs. Rotating residential proxies distribute requests across fresh IPs. Second, Google Flights personalizes prices based on the IP's geographic location. A US proxy IP returns USD prices as a US-based traveler would see them. A UK proxy returns GBP prices. If you're doing multi-market research, you need country-matched proxy IPs, not just URL parameters.
The tfs parameter is a Base64-encoded Protocol Buffer (protobuf) string that encodes your entire search query: departure and arrival airport codes, date, trip type, seat class, and passenger count. Google uses protobuf for efficiency instead of plain query parameters. The fast-flights library generates and decodes these strings automatically. You pass IATA codes and dates, it handles the encoding.
Airlines and travel booking systems practice market-based dynamic pricing. The same seat on the same flight is listed at different prices in different regional markets based on local demand, purchasing power, and regional competition. Google Flights also uses your IP's geographic location to personalize displayed prices and currencies, separate from the gl URL parameter. Both the parameter and a country-matching proxy IP are needed for geo-accurate pricing data.