Google Flights Scraper & Price Monitor
Pricing
from $5.00 / 1,000 successful flight searches
Google Flights Scraper & Price Monitor
Collect Google Flights fares for travel apps and recurring price comparisons. Get paired round-trip totals, flight segments, booking links, and stable itinerary IDs. Batch up to 100 searches and export JSON or CSV. Schedule runs through Apify and compare saved results.
What does Google Flights Scraper & Price Monitor do?
Collect Google Flights fares for travel apps and recurring price comparisons. This Google Flights scraper returns one-way and paired round-trip itineraries, total-party fares, physical flight segments and Google booking links. Search a single route or batch up to 100 route-and-date queries, then use JSON/CSV exports or the Apify API in your application. It is an independent Google Flights API alternative.
Coverage is limited to observed options, rather than exhaustive flight inventory. For flight price monitoring, schedule runs through Apify, save each run's results and compare them yourself using stable itinerary IDs. The Python tutorial below shows that workflow.
Why use it for recurring fare comparisons?
- Join observations across runs.
itineraryIdexcludes the current price, so you can compare the same itinerary over time while keeping the search settings fixed. - Use paired round-trip totals. Each round-trip row includes selected outbound and inbound flights with Google's total fare for the passenger party.
- Keep currency comparisons consistent. Each exported fare's source currency is checked against the requested currency.
- Collect multiple routes in one run. Batch up to 100 searches, with labels and per-search overrides, for route analysis or an application's data pipeline.
- Understand collection outcomes.
RUN_SUMMARYrecords per-search status, partial failures and coverage limits so missing results are not mistaken for missing flights.
Quick start: collect Google Flights prices
- Open the Input tab, select JSON input and paste this one-way example.
- Optionally add a future
departureDateinYYYY-MM-DDformat. For this one-off quick start, omitting it searches 30 days after the run date in UTC. - Set your maximum cost per run to at least $0.00505, then start the Actor.
- Inspect the flight dataset, download its JSON or CSV export, and open the linked run summary to review coverage and status.
{"origin": "JFK","destination": "LHR","currency": "USD","maxResultsPerSearch": 10,"maxRequestsPerRun": 3}
For repeated observations of one journey, use fixed travel dates as shown below. The date default moves forward each day.
What data does it return?
| Field | Type | Meaning |
|---|---|---|
searchId | string | Stable key for normalized search settings, including route, dates, filters and passengers |
itineraryId | string | Stable itinerary comparison key that excludes the current price |
observedAt | string | UTC observation start; options are fetched over the following seconds |
price, currency, priceBasis | number, strings | Source-verified total-party fare, currency and one-way/round-trip basis |
outbound, inbound | arrays | Physical segments with airports, airlines, flight numbers and local times |
googleBookingUrl | string | Google itinerary link encoding the selected flights |
The abbreviated round-trip record below is a historical observation from 8 October 2026, for travel in November 2026. Its $692 fare is historical test data, not a live offer. Both directions are included in that one total; do not add another outbound price.
{"searchId": "734e74e97d3dc2d3397e6cbd","itineraryId": "3ddbaad329a3b242c7fae40c","observedAt": "2026-10-08T13:59:25.710+00:00","tripType": "round-trip","origin": "JFK","destination": "LHR","departureDate": "2026-11-10","returnDate": "2026-11-17","price": 692,"currency": "USD","priceBasis": "total-party-round-trip","outbound": [{"origin": "JFK","destination": "DUB","departureTime": "2026-11-10T17:00","arrivalTime": "2026-11-11T04:30","airlineCode": "EI","flightNumber": "104"},{"origin": "DUB","destination": "LHR","departureTime": "2026-11-11T06:35","arrivalTime": "2026-11-11T08:05","airlineCode": "EI","flightNumber": "152"}],"inbound": [{"origin": "LHR","destination": "ZRH","departureTime": "2026-11-17T06:00","arrivalTime": "2026-11-17T08:40","airlineCode": "LX","flightNumber": "345"},{"origin": "ZRH","destination": "JFK","departureTime": "2026-11-17T09:55","arrivalTime": "2026-11-17T13:15","airlineCode": "LX","flightNumber": "16"}],"googleBookingUrl": "https://www.google.com/travel/flights/booking?tfs=CBwQAhpgEgoyMDI2LTExLTEwIh8KA0pGSxIKMjAyNi0xMS0xMBoDRFVCKgJFSTIDMTA0Ih8KA0RVQhIKMjAyNi0xMS0xMRoDTEhSKgJFSTIDMTUyagcIARIDSkZLcgcIARIDTEhSGl8SCjIwMjYtMTEtMTciHwoDTEhSEgoyMDI2LTExLTE3GgNaUkgqAkxYMgMzNDUiHgoDWlJIEgoyMDI2LTExLTE3GgNKRksqAkxYMgIxNmoHCAESA0xIUnIHCAESA0pGS0ABSAFwAYIBCwj___________8BmAEB&hl=en&gl=US&curr=USD"}
How to monitor Google Flights prices with Python
Save two observations of the same journey
- Choose a future departure date and save this input as a task in your own Apify account. This example uses 15 January 2027; update it before that date passes. Add a fixed
returnDatefor a round trip. - Run the task. Download the full dataset as JSON as
before.json, includingsearchId,itineraryId,observedAt,price,currencyandpriceBasis. Export all rows and fields, without a table-view projection, row limit or field filter. Save that run's key-value recordRUN_SUMMARYasbefore-summary.json. - Run the identical input later, manually or with an Apify schedule. Save the second dataset as
after.jsonand its ownRUN_SUMMARYasafter-summary.json. - Save the complete program below as
compare_fares.pybeside those four files and execute the command beneath it. Python 3.11 or later is sufficient; no packages are required.
{"origin": "JFK","destination": "LHR","departureDate": "2027-01-15","currency": "USD","country": "US","cabin": "economy","adults": 1,"maxResultsPerSearch": 10,"maxRequestsPerRun": 3}
Keep the same route, travel dates, cabin, passengers, currency, country edition, language, filters and sorting. searchId captures those normalized settings; label and operational limits are excluded. Keep the collection limits consistent too, since they affect coverage. Omitting the departure date would select UTC today plus 30 days, comparing different journeys on different days.
Missing results are not proof of unavailability. Even complete searches have bounded coverage. A changed departure time creates a new itinerary ID and appears as unmatched observations. Use itineraryId to join runs; observationId changes with observation time.
Copyable comparison program
This local program reads one search's JSON exports, verifies matching searches and chronological observations, and preserves coverage warnings. It rejects error and skipped searches. A batch must first be reduced to the selected search entry and only that search's rows in both exports. It does not start runs or send notifications.
"""Compare two single-search JSON exports locally using Python 3.11+ only."""import argparseimport jsonimport reimport sysfrom datetime import datetimefrom decimal import Decimalfrom pathlib import Pathdef required_text(value, key):text = value.get(key)if not isinstance(text, str) or not text.strip():raise ValueError(f'Missing or invalid {key}')return textdef timestamp(value):if not isinstance(value, str):raise ValueError('observedAt must be an ISO timestamp with a timezone')try:parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))except ValueError as error:raise ValueError('Invalid observedAt timestamp') from errorif parsed.tzinfo is None:raise ValueError('observedAt must include a timezone')return parseddef observation(rows, summary):if not isinstance(rows, list) or not isinstance(summary, dict):raise ValueError('Dataset must be a JSON array and summary a JSON object')searches = summary.get('searches')if not isinstance(searches, list) or len(searches) != 1:raise ValueError('Select a single search and its rows from each batch summary')search = searches[0]if not isinstance(search, dict):raise ValueError('Each summary search must be an object')status = search.get('status')if status not in ('complete', 'partial', 'empty'):raise ValueError('Cannot compare an error, skipped, or unknown search status')search_id = required_text(search, 'searchId')observed_at = required_text(search, 'observedAt')timestamp(observed_at)query = search.get('search')if not isinstance(query, dict):raise ValueError('Summary must include the normalized search object')currency = required_text(query, 'currency')if not re.fullmatch(r'[A-Z]{3}', currency):raise ValueError('Invalid summary currency')basis = 'total-party-round-trip' if query.get('returnDate') else 'total-party-one-way'count = search.get('resultCount')if type(count) is not int or count != len(rows):raise ValueError('Dataset row count must match summary resultCount; export all rows')if (status == 'empty') != (count == 0):raise ValueError('Summary status and resultCount are inconsistent')indexed = {}for row in rows:if not isinstance(row, dict):raise ValueError('Each dataset row must be an object')itinerary_id = required_text(row, 'itineraryId')if required_text(row, 'searchId') != search_id:raise ValueError('Dataset and summary searchId values differ')if timestamp(row.get('observedAt')) != timestamp(observed_at):raise ValueError('Dataset and summary observedAt values differ')if row.get('currency') != currency or row.get('priceBasis') != basis:raise ValueError('Dataset currency or priceBasis differs from the search')price = row.get('price')if isinstance(price, bool) or not isinstance(price, (int, float, Decimal)):raise ValueError('Each price must be a finite positive JSON number')price = Decimal(str(price))if not price.is_finite() or price <= 0:raise ValueError('Each price must be a finite positive JSON number')if itinerary_id in indexed and indexed[itinerary_id] != row:raise ValueError(f'Conflicting duplicate itineraryId: {itinerary_id}')indexed[itinerary_id] = rowwarnings = ['Coverage is bounded; missing itineraries do not establish unavailability.']if status != 'complete':warnings.append(f'Search status is {status}; results may be incomplete or absent.')if search.get('outputTruncated'):warnings.append('Output was truncated by the configured result limit.')if search.get('coverage'):warnings.append(str(search['coverage']))if search.get('unpricedOptionsSkipped'):warnings.append(f"Unpriced options skipped: {search['unpricedOptionsSkipped']}")issues = search.get('issues', [])if not isinstance(issues, list):raise ValueError('Summary issues must be an array')for issue in issues:warnings.append('Source issue: ' + json.dumps(issue, default=str, sort_keys=True))return {'searchId': search_id, 'status': status, 'observedAt': observed_at,'currency': currency, 'priceBasis': basis, 'warnings': warnings,}, indexeddef compare(before, before_summary, after, after_summary):previous, old = observation(before, before_summary)current, new = observation(after, after_summary)if previous['searchId'] != current['searchId']:raise ValueError('searchId values differ; repeat the identical normalized search')if (previous['currency'], previous['priceBasis']) != (current['currency'], current['priceBasis']):raise ValueError('Currency and priceBasis must match between observations')if timestamp(current['observedAt']) <= timestamp(previous['observedAt']):raise ValueError('The after observation must be later than the before observation')changes = []for itinerary_id in sorted(old.keys() | new.keys()):earlier, later = old.get(itinerary_id), new.get(itinerary_id)old_price = Decimal(str(earlier['price'])) if earlier else Nonenew_price = Decimal(str(later['price'])) if later else Nonedifference = new_price - old_price if earlier and later else Noneif earlier is None:status = 'newly_observed'elif later is None:status = 'not_observed_later'else:status = 'decreased' if difference < 0 else 'increased' if difference > 0 else 'unchanged'changes.append({'itineraryId': itinerary_id, 'status': status,'previousPrice': format(old_price, 'f') if old_price is not None else None,'currentPrice': format(new_price, 'f') if new_price is not None else None,'difference': format(difference, 'f') if difference is not None else None,'currency': current['currency'], 'priceBasis': current['priceBasis'],'previousObservedAt': earlier['observedAt'] if earlier else None,'currentObservedAt': later['observedAt'] if later else None,})return {'searchId': current['searchId'], 'before': previous, 'after': current, 'changes': changes}def reject_constant(value):raise ValueError(f'Non-finite JSON number: {value}')def read_json(path):return json.loads(Path(path).read_text(encoding='utf-8-sig'),parse_float=Decimal, parse_constant=reject_constant)def main():parser = argparse.ArgumentParser(description=__doc__)for name in ('before', 'before-summary', 'after', 'after-summary'):parser.add_argument('--' + name, required=True)args = parser.parse_args()try:report = compare(read_json(args.before), read_json(args.before_summary),read_json(args.after), read_json(args.after_summary))except (OSError, UnicodeError, ValueError) as error:print(f'Cannot compare fares: {error}', file=sys.stderr)return 1print(json.dumps(report, indent=2))return 0if __name__ == '__main__':sys.exit(main())
Run this as one command on Windows, macOS or Linux:
python compare_fares.py --before before.json --before-summary before-summary.json --after after.json --after-summary after-summary.json
The program writes its JSON report to stdout and validation errors to stderr with a nonzero exit code. Monetary values and differences are decimal strings to preserve exact arithmetic. difference is current minus previous fare in currency units; it is null for unmatched observations. Each entry includes the observation timestamps where present. Collection statuses and warnings are retained under before and after.
Try a constructed example offline
The following fixture generator is illustrative, with abbreviated rows and demonstration IDs. It does not represent historical fares or a Google search. Save it as create_fixtures.py beside compare_fares.py, then run python create_fixtures.py. It creates the four JSON files used by the command above in the same folder. Use a separate folder to avoid overwriting your own exports.
"""Write constructed comparison fixtures; these are not historical fare offers."""import jsonfrom pathlib import Pathdef create(directory):directory = Path(directory)directory.mkdir(parents=True, exist_ok=True)query = {'origin': 'JFK', 'destination': 'LHR', 'departureDate': '2027-01-15','currency': 'USD', 'country': 'US', 'cabin': 'economy', 'adults': 1}for name, observed_at, fares in [('before', '2026-10-17T10:00:00+00:00', {'demo-a': 320.25, 'demo-b': 450, 'demo-c': 510}),('after', '2026-10-18T10:00:00+00:00', {'demo-a': 299.99, 'demo-b': 450, 'demo-d': 475}),]:rows = [{'searchId': 'demo-search', 'itineraryId': key, 'observedAt': observed_at,'price': price, 'currency': 'USD', 'priceBasis': 'total-party-one-way'}for key, price in fares.items()]summary = {'searches': [{'searchId': 'demo-search', 'search': query, 'status': 'complete','observedAt': observed_at, 'resultCount': len(rows), 'coverageLimited': True,'coverage': 'Constructed fixture with bounded illustrative coverage.','outputTruncated': False, 'issues': [], 'unpricedOptionsSkipped': 0}]}for filename, value in [(f'{name}.json', rows), (f'{name}-summary.json', summary)]:(directory / filename).write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8')if __name__ == '__main__':create(Path(__file__).resolve().parent)
The constructed report contains these four observations:
| Itinerary | Classification | Previous USD | Current USD | Difference USD |
|---|---|---|---|---|
demo-a | decreased | 320.25 | 299.99 | -20.26 |
demo-b | unchanged | 450 | 450 | 0 |
demo-c | not_observed_later | 510 | null | null |
demo-d | newly_observed | null | 475 | null |
For example, the first entry in the report's changes array is:
{"itineraryId": "demo-a","status": "decreased","previousPrice": "320.25","currentPrice": "299.99","difference": "-20.26","currency": "USD","priceBasis": "total-party-one-way","previousObservedAt": "2026-10-17T10:00:00+00:00","currentObservedAt": "2026-10-18T10:00:00+00:00"}
partial and empty searches can be compared, but carry explicit warnings. Missing rows remain observations about collection, not conclusions about cancellations, sold-out flights or price increases.
Batch searches and public example tasks
Use these two configurations to save tasks in your own account. Update their travel dates before running. Each example is limited to 10 results per search, disables optional proxies, and allows one retry. A $0.02 maximum cost per run covers their configured Actor charges. Copied tasks keep your own dates and settings; schedules must be configured separately.
Compare Google Flights prices across multiple routes
Try the public route-comparison example, or copy this input into your own task.
Collect JFK–London and JFK–Paris one-way fares for the same day, in USD. Export the observations for route analysis or an application; the Actor does not automatically produce a route-ranking report.
{"currency": "USD","country": "US","language": "en","cabin": "economy","adults": 1,"maxResultsPerSearch": 10,"maxRetries": 1,"proxyConfiguration": {"useApifyProxy": false},"searches": [{"label": "London","origin": "JFK","destination": "LHR","departureDate": "2027-01-15"},{"label": "Paris","origin": "JFK","destination": "CDG","departureDate": "2027-01-15"}],"maxRequestsPerRun": 10}
Use searches or the single-search route/date fields, never both. Each batch entry requires origin, destination and departureDate; it may override global settings and carry a label. Equivalent duplicate searches run once, using the first label.
Collect round-trip Google Flights fares and itineraries
Try the public round-trip example, or copy this input into your own task.
Collect JFK–London options for a seven-day journey. Each row includes paired directions and one total-party fare. The configuration expands two outbound options, so round-trip coverage is bounded by that choice. Google can return an internal error for an expanded outbound. In that case useful pairs are retained with a partial summary; inspect that summary before comparing observations. If no usable options can be collected, the run reports an error instead of treating the response as no availability.
{"currency": "USD","country": "US","language": "en","cabin": "economy","adults": 1,"maxResultsPerSearch": 10,"maxRetries": 1,"proxyConfiguration": {"useApifyProxy": false},"origin": "JFK","destination": "LHR","departureDate": "2027-01-15","returnDate": "2027-01-22","maxOutboundOptions": 2,"maxRequestsPerRun": 6}
API access and JSON/CSV exports
Use the API tab to generate authenticated requests and the Input tab for the complete configuration. The Actor identifier is vincesoft/google-flights-price-monitor. Pass the same JSON input and set the maximum charge limit in your API run options. Apify's MCP tools can also invoke the Actor with this input.
You can download the dataset in JSON, HTML, CSV or Excel. Each row is one observed itinerary option; the separate RUN_SUMMARY key-value record explains collection outcomes. Retain both for recurring comparisons. The default table shows 10 useful columns, while the full dataset has 28 top-level fields. The comparison program requires the full JSON export, not a download restricted to the default table columns.
Pricing
$0.005 per successful search ($5/1,000 searches), charged once per unique query that delivers at least one validated option. A partial search with useful results is charged once. Failed, empty and skipped searches receive no search charge. Apify also charges $0.00005 per run for the Actor start event, even when no search returns results. There is no per-itinerary dataset charge.
One successful search costs $0.00505 in Actor charges. Two successful searches in one run cost $0.01005. The minimum allowed charge limit is $0.00505. If the remaining budget cannot cover another batch search, it is skipped and recorded in RUN_SUMMARY.
One-way searches normally require one Google request. A round trip requires an outbound request plus one return-selection request per expanded outbound, before retries. maxRequestsPerRun caps Google requests across the entire run; it is an operational limit, separate from the monetary spending limit. Optional proxies can add platform costs and are disabled by default. The Actor runs at 256 MB and requires no paid upstream search API.
Coverage, data quality, and troubleshooting
The Actor reads observed options in Google's initial page data. It does not claim exhaustive inventory. Round trips expand only the configured number of outbound options; their prices come from Google's return-selection page and are never the sum of an outbound estimate and a round-trip total.
Currency is read from the source fare token and checked against the requested currency. Unpriced options are skipped and counted in unpricedOptionsSkipped. Google can withhold inline fares for children or infants, reducing coverage. Missing optional fields remain null. Segment timestamps use each airport's local clock without an inferred UTC offset. requestedCountry is the requested Google edition, not independent proof of the proxy's point of sale.
maxStops is -1 for unrestricted, 0 for nonstop, 1 for up to one connection, and 2 for up to two per direction. Optional airlines filters are sent to Google; codeshares can involve other operators. maxPrice limits the total party fare. sortBy supports price, duration, departure and google. Passenger counts are limited to nine total, with no more lap infants than adults.
What do the search statuses mean?
| Status | Meaning |
|---|---|
complete | The configured observed-options workload finished without detected extraction issues; coverage is still limited. |
partial | Usable options were saved with collection/validation issues or an explicit passenger-coverage warning. |
empty | No matching validated options were observed; this does not prove no flights operate. |
error | Extraction or return expansion failed without a usable option. |
skipped | A stop or spending limit prevented starting the search. |
The summary includes validation issues, output truncation, outbound expansion counts, request counts, response bytes and duration. If every search errors without usable options, the Actor fails after saving the summary. A mixed batch preserves useful results.
What if Google blocks a request or changes its page structure?
Retries stay within configured limits. Unrecognized pages are recorded as errors or partial results, rather than proof of no availability. Review RUN_SUMMARY first. Stop/resurrection handling saves progress at completed-search boundaries; storage writes and charges are not a single transaction, so abrupt process failure can require reconciling output and charges.
FAQ and support
Is this an official Google Flights API?
No. It is independent of Google and extracts public flight-search information. Use it according to applicable website terms and data-use requirements. It does not collect account access, personal traveler data or profiles.
Are price alerts and history built in?
The Actor collects current observations. Use Apify schedules to repeat a task, retain each run's dataset and summary, and compare the saved results using the tutorial. Your application supplies durable history, alerts and notifications.
How are round-trip prices represented?
Each paired round-trip row contains outbound and inbound physical flight segments and a total fare for the requested passenger party. priceBasis is total-party-round-trip. A one-way row uses total-party-one-way and an empty inbound array.
Does the booking link reserve seats or guarantee the fare?
No. It opens a selected Google itinerary. Prices and availability can change before reaching the supplier. The Actor does not resolve individual airline/OTA offers or complete bookings.
How do I report an issue?
Submit reproducible input, the error code and relevant summary details through the Issues tab. Omit credentials. Use the API tab for programmatic invocation examples.