I run a visa agency in Bali. Half my clients' questions are not "how much is the flight" but "which week is cheapest", so one of the Apify Actors I maintain does nothing except pull Google Flights' price calendar — the strip of per-day fares above the search box — and hand it back as rows.
For one-way trips it behaves exactly the way the name promises. Today, 29 October 2026:
{
"origin": "DPS",
"destination": "SIN",
"departureDate": "2026-11-20",
"calendarDays": 21,
"calendarOnly": true
}
21 rows, one per date, lowest $60 on 26 November, highest $82, and a free summary row on the end:
11-20 70 11-27 70 12-04 62
11-21 80 11-28 76 12-05 62
11-22 82 11-29 80 12-06 80
11-23 71 11-30 80 12-07 70
11-24 70 12-01 63 12-08 70
11-25 70 12-02 70 12-09 70
11-26 60 12-03 62 12-10 62
Then I added one field — "returnDate": "2026-11-27", a seven-day trip — and kept everything else identical. Same route, same start date, same calendarDays: 21.
147 rows.
Run summary: 148 row(s) delivered, 0 free; charged flight ×147 = $0.0294.
Seven times the data and seven times the bill for what looks like the same question. Nothing in the input hints at it, and for a while I assumed my own windowing code had gone wrong.
A round trip has no "price for a day"
It had not. The grid is the correct answer to a question I had not noticed I was asking. A one-way fare is a function of one date; a round-trip fare is a function of two. Google's calendar endpoint knows this, and when the request carries a return range it answers in two dimensions:
departure dates in the 147 rows: 21 (2026-11-20 .. 2026-12-10)
return dates in the 147 rows: 21 (2026-11-27 .. 2026-12-17)
rows per departure date: 7 (every single one)
21 × 7. Each departure date arrives with seven different return dates attached, so tripLengthDays is not 7 across the dataset — it is a triangle centred on the length I asked for:
trip length 1 2 3 4 5 6 7 8 9 10 11 12 13
rows 3 6 9 12 15 18 21 18 15 12 9 6 3
That shape falls straight out of how the data is fetched. One-way calendars come back in windows of about 90 days, so 21 days — or 60 — is a single request. Round trips come back as a 7 × 7 block of departure × return dates per request, which is what the site itself asks for when you open the date picker on a return flight. Three blocks cover 21 days; the diagonal band they trace is why a seven-day trip request also prices one-day and thirteen-day trips. 60 days is 9 requests, 120 days is 18. My Actor runs 6 at a time and the round trip above finished in 44 seconds, versus 10 for the one-way.
That concurrency exists because of a real failure. The windows used to go one at a time, a 120-day round trip took around two and a half minutes, and buyers running with a 100- or 180-second timeout got zero rows — the watchdog ended the run before anything was pushed. Now whatever finished is saved and a free notice row says how many days the clock cut off.
The field that quietly stops answering
The grid is honest. The convenience fields on top of it are the part that will bite you.
The summary row carries cheapestDates, the five lowest entries in the calendar, which on a one-way search is exactly what you want: five distinct dates to go look at. On the round trip it reads:
{
"days": 21,
"datesWithPrices": 147,
"lowestPrice": 139,
"lowestPriceDate": "2026-11-20",
"lowestPriceReturnDate": "2026-11-29",
"cheapestDates": [
{ "date": "2026-11-20", "returnDate": "2026-11-29", "price": 139 },
{ "date": "2026-11-20", "returnDate": "2026-11-30", "price": 139 },
{ "date": "2026-11-20", "returnDate": "2026-12-02", "price": 139 },
{ "date": "2026-11-20", "returnDate": "2026-12-03", "price": 139 },
{ "date": "2026-11-24", "returnDate": "2026-11-29", "price": 139 }
]
}
Four of the five cheapest entries are the same departure date. Eleven further departure dates also reach $139 in this dataset and not one of them appears, because the five-cheapest list sorts pairs, and a route with a wide price floor fills the list from whichever corner of the grid sorts first. Read it as "five cheapest days to fly" and you have been handed one day, four times.
datesWithPrices: 147 has the same problem: it counts priced pairs and calls them dates. There are 21 dates. I wrote that field for the one-way case and never renamed it.
And the answer to the question I actually asked — cheapest seven-day trip in this window — is in the data but not in the summary:
import json, collections
rows = [r for r in json.load(open("calendar.json")) if r["type"] == "calendar_day"]
seven = [r for r in rows if r["tripLengthDays"] == 7] # 21 of 147
print(min(seven, key=lambda r: r["price"])) # $139, 25 Nov
best = collections.defaultdict(list) # or: cheapest per day, any length
for r in rows:
best[r["date"]].append(r["price"])
print({d: min(p) for d, p in sorted(best.items())})
lowestPriceDate says 20 November. The cheapest seven-day trip leaves on the 25th, 26th, 2nd, 3rd or 9th, all at $139 — the 20th only wins because it is allowed a nine-day return. If your user picked a trip length, filter on tripLengthDays yourself and ignore the summary.
Two more things worth knowing before you join this onto anything:
date is the row's departure date. departureDate is your input. Every one of those 147 rows says "departureDate": "2026-11-20", including the row for 10 December. The input echo is there so you can group a multi-route sweep back to its searches; it is not the date the price belongs to. Join on departureDate and you will collapse the whole grid onto one day.
The return date moves the price about as much as the departure date does. Taking the cheapest return for each departure date, the best days span $139–$166 — a $27 spread. Inside a single departure date the spread reaches $25 (30 November: $145 to $170). A one-dimensional calendar would have shown you half the lever.
Where it stops: 330 days out
The other hard edge is the booking horizon. Airlines load schedules roughly eleven months ahead, and Google refuses an entire calendar window if any day in it is past that. Asked today for 60 days from 1 September 2027:
rows: 14 (2027-09-01 .. 2027-09-14)
notice: Price calendar: 46 of 60 day(s) are past 2027-09-14, beyond Google
Flights' booking window — airlines load schedules only about 11 months
(~330 days) ahead. Days up to 2027-09-14 were fetched; the rest are not
charged. Run again later for the later dates.
Days beyond the horizon are never requested, which matters because the alternative is not an empty row — it is an empty window. Before I added that guard, a request for a quarter starting past the horizon came back blank on every retry and the buyer read it as throttling and ran it again. The notice row is free, and so are the 46 days.
One more default to leave alone: residential proxies. The calendar endpoint has never answered me from a datacenter address, where the ordinary results endpoint usually will. Occasional refusals from residential IPs are genuinely temporary — the same request answers within minutes — so the retry loop is worth more than a bigger proxy pool here.
Calling it
One POST, rows in the response:
curl -X POST "https://api.apify.com/v2/acts/lergassy~flight-price-calendar/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"origin":"DPS","destination":"SIN","departureDate":"2026-11-20",
"returnDate":"2026-11-27","calendarDays":21,
"calendarOnly":true,"includeSearchSummary":true}'
That is Flight Price Calendar. Charging is per row — $0.20 per 1,000, no run fee, no per-search fee — so the 21-day one-way cost $0.0042 and the round-trip grid $0.0294. Pass routes instead of origin/destination to sweep many routes in one run, and drop calendarOnly if you want the itinerary list alongside the grid.
When it is the wrong tool
If you need the actual flights — airlines, times, layovers, booking links — a calendar is the wrong shape; it has one number per date and no itinerary behind it. If you need a long-range series, this is not it either: it is today's snapshot of future dates, not history, and you have to store it yourself to get a trend. And if your route is thin enough that most days have no fare at all, expect fewer rows than calendarDays and read datesWithPrices before you plot anything — missing days are dropped, not zero-filled.
The lesson I keep rediscovering in this corner of travel data: the dimensionality of the answer is set by the input, not by the field names. One date in, a list out. Two dates in, a matrix out — wearing the same schema, the same summary row and the same price per row.
Top comments (0)