A Python automation that watches flight prices to a list of destinations and emails every subscribed user the moment it finds a deal cheaper than the current lowest recorded price — built around a Google Sheet acting as a lightweight database, live flight data from SerpApi's Google Flights engine, and Gmail for delivery.
Set your destinations and target prices once in a spreadsheet. The script checks live prices, updates the sheet when it finds something cheaper, and emails everyone subscribed — no manual price-checking required.
A real run against four tracked destinations — note how it automatically falls back to an indirect search when no direct flight is found, and only emails when a genuinely new low price is recorded:
- 📊 Google Sheets as a database — destinations, IATA codes, and lowest recorded prices are managed entirely in a spreadsheet via the Sheety API, no SQL setup needed.
✈️ Live flight search powered by SerpApi's Google Flights engine, checking real prices for every destination on the list.- 📉 Automatic price-drop detection — compares each live result against the previously recorded lowest price and only acts when a genuinely better deal appears.
- 🔄 Self-updating records — the moment a new low price is found, it's written straight back to the Google Sheet via a
PUTrequest. - 🔁 Indirect flight fallback — if no direct flight exists for a route, the script automatically retries the search allowing connecting flights.
- 📧 Mass email notifications — every subscribed user (pulled from a second Sheety-powered "users" sheet) gets notified automatically when a deal is found.
- 📝 Public sign-up form — a Google Form lets anyone subscribe with their name and email; responses flow straight into the Google Sheet that powers the notification list, with zero manual entry.
- ⚡ HTTP response caching via
requests_cacheto avoid hammering the flight search API with repeat requests.
| Category | Tool / Concept |
|---|---|
| Language | Python 3 |
| Flight Data | SerpApi — Google Flights search engine |
| Spreadsheet-as-Database | Sheety — turns a Google Sheet into a REST API |
| HTTP Requests | requests, requests_cache |
smtplib (standard library) — SMTP + TLS email delivery |
|
| Secrets Management | python-dotenv |
| Paradigm | Object-Oriented Programming — separate classes for data, search, and notifications |
| Core Concepts | REST API integration (GET/POST/PUT), JSON parsing, date handling, batch email delivery |
Flight Deals Finder/
│
├── main.py # Entry point — orchestrates the full price-check workflow
├── data_manager.py # DataManager — reads/writes the Google Sheet via Sheety
├── flight_search.py # FlightSearch — queries SerpApi for live flight prices
├── flight_data.py # FlightData class + logic to find the cheapest result
├── notification_manager.py # NotificationManager — sends email alerts via Gmail
├── experiment.ipynb # Scratch notebook for prototyping/testing API calls outside the main pipeline
├── .env # API keys, Sheety endpoints, and Gmail credentials (never committed)
├── .gitignore # Excludes .env and local cache databases from version control
└── README.md
Each module owns one responsibility — data_manager.py only talks to the spreadsheet, flight_search.py only talks to SerpApi, notification_manager.py only sends email — with main.py acting purely as the orchestrator that ties them together.
📌 Note:
requests_cachegenerates local.sqlitecache files (e.g.flight_cache.sqlite,flight_search.sqlite) at runtime. These are environment-specific and should be listed in.gitignorerather than committed to the repo.
Subscribers join the notification list through a simple public Google Form — just first name, last name, and email. No account or login required.
| Field |
|---|
| Enter your first name? |
| Enter your last name? |
| Enter your email address? |
Every submission lands automatically in a Form_Responses tab of the same Google Sheet, which Sheety exposes as SHEETY_USER_ENDPOINT for get_customer_emails() to read.
A separate tab holds the destinations being watched, each with its IATA code and the lowest price recorded so far — this is what data_manager.py reads from and writes back to.
| City | IATA Code | Lowest Price |
|---|---|---|
| Mumbai | BOM | 10637 |
| Delhi | DEL | 16681 |
| Ahmedabad | AMD | 13524 |
| Kolkata | CCU | 7735 |
This sheet is the single source of truth the whole app runs on — add a new row here and the script starts tracking that destination on the next run, no code changes required.
- Python 3.x installed
- A Google Sheet set up with Sheety (one sheet for destinations/prices, one for subscribed user emails)
- A free or paid SerpApi account and API key
requests,requests_cache, andpython-dotenv— install via pip:
pip install requests requests_cache python-dotenv1. Create your .env file in the project root:
SHEET_ENDPOINT=your_sheety_prices_endpoint
SHEETY_USER_ENDPOINT=your_sheety_users_endpoint
SERP_ENDPOINT=https://serpapi.com/search
SERP_KEY=your_serpapi_key
SMTP_GMAIL_ID=your_email@gmail.com
SMTP_GMAIL_PASSWORD=your_app_password
⚠️ Use a Gmail App Password, not your real Gmail password. Add.envto your.gitignoreso it's never pushed to GitHub.
2. Set up your Google Sheet with a destinations tab (City, IATA Code, Lowest Price columns) and a Form_Responses tab for subscribers. Create a Google Form with first name, last name, and email fields, and link its responses to the Form_Responses tab — see Data Sources below for the exact structure.
python main.pySince this is meant to run unattended, schedule it with cron, Windows Task Scheduler, GitHub Actions, or PythonAnywhere's Scheduled Tasks — daily or a few times a week is typical for price-tracking.
DataManager wraps every spreadsheet interaction — fetching the destination list, pulling subscriber emails, and later writing updated prices back — so the rest of the app never talks to Sheety directly.
class DataManager:
"""Read and update destination pricing and customer records stored in Sheety."""
def __init__(self):
"""Load the API and user endpoints from environment variables."""
self.API_ENDPOINT = os.getenv("SHEET_ENDPOINT")
self.USER_ENDPOINT = os.getenv("SHEETY_USER_ENDPOINT")
def get_data(self):
"""Fetch all destination rows from the Sheety spreadsheet."""
try:
r = requests.get(url=f"{self.API_ENDPOINT}")
r.raise_for_status()
data = r.json()
return data
except r.raise_for_status as error:
return error
def update_lowest_price(self, row_id, new_price):
"""Update the stored lowest price for a specific destination row."""
try:
update_price = {"price": {"lowestPrice": new_price}}
r = requests.put(url=f"{self.API_ENDPOINT}/{row_id}",
json=update_price)
return r.json()
except KeyError as e:
print(e)
def get_customer_emails(self):
"""Fetch the list of subscribed customer records and return their details."""
r = requests.get(url=self.USER_ENDPOINT)
r.raise_for_status()
user_data = r.json()
user_data = user_data["users"]
return user_dataFlightSearch queries SerpApi's Google Flights engine for a given route and date, wrapping the request in a cached session so repeat lookups don't waste API calls.
def check_flights(self, from_city_code, to_city_code, departure_date,
return_date=None, is_direct=True):
"""Return raw JSON flight results for the requested route and dates."""
parameters = {
"engine": "google_flights",
"departure_id": from_city_code,
"arrival_id": to_city_code,
"outbound_date": departure_date.strftime("%Y-%m-%d"),
"type": "2",
"adults": "1",
"currency": "INR",
"stops": "0",
"api_key": self.SERP_KEY,
}
# When is_direct is True, request results limited to non-stop itineraries.
if is_direct:
parameters["stops"] = "1"
try:
session = requests_cache.CachedSession("flight_search")
r = session.get(url=SERP_ENDPOINT, params=parameters)
data = r.json()
return data
except r.raise_for_status as error:
print(error)find_cheapest_flight() scans through every flight SerpApi returned (combining both "best" and "other" results) and keeps track of whichever has the lowest price. The final destination is read from the last leg of the itinerary, so connecting flights report where the traveler actually lands rather than the first layover.
def find_cheapest_flight(data, return_date=None):
"""Return the lowest-priced itinerary from the SerpAPI response payload."""
best_flights = data.get("best_flights", [])
other_flights = data.get("other_flights", [])
all_flights = best_flights + other_flights
if all_flights is None or not best_flights and not other_flights:
print("Sorry!, No Flights Available.")
return FlightData("N/A", "N/A", "N/A",
"N/A", "N/A")
first_flight = all_flights[0]
lowest_price = first_flight["price"]
departure = first_flight["flights"][0]["departure_airport"]["id"]
arrival = first_flight["flights"][0]["arrival_airport"]["id"]
journey_date = \
first_flight["flights"][0]["departure_airport"]["time"].split(" ")[0]
nr_stops = len(first_flight["flights"]) - 1
cheapest_flight = FlightData(lowest_price, departure, arrival,
journey_date,
nr_stops)
for flight in all_flights:
try:
price = flight["price"]
except KeyError:
print("No data available.")
continue
if price <= lowest_price:
lowest_price = price
departure = first_flight["flights"][0]["departure_airport"]["id"]
arrival = first_flight["flights"][-1]["arrival_airport"]["id"]
journey_date = \
first_flight["flights"][0]["departure_airport"]["time"].split(
" ")[
0]
nr_stops = len(first_flight["flights"]) - 1
cheapest_flight = FlightData(lowest_price, departure, arrival,
journey_date, nr_stops)
print(f"Lowest price to {arrival} is INR {lowest_price}/-")
return cheapest_flightmain.py is the orchestrator — for every destination in the sheet, it searches live prices, compares against the recorded lowest, falls back to indirect flights if no direct option exists, and triggers an email when a genuinely cheaper deal is found.
for city in sheet_data["prices"]:
search_flight = search.check_flights(departure_city,
city["iataCode"],
departure_date=tomorrow,
)
flight_detail = find_cheapest_flight(search_flight).__dict__
cheapest_flight = flight_detail["price"]
sheet_price = sheet_data["prices"][i]["lowestPrice"]
i += 1
if cheapest_flight == "N/A":
pprint(f"No direct flight to {city['city']}."
f" Searching for indirect flights...")
connected_flights = search.check_flights(
departure_city,
city["iataCode"],
departure_date=tomorrow,
is_direct=False,
)
low_cost_flight = find_cheapest_flight(connected_flights)
low_cost_price = low_cost_flight.price
print(
f"Cheapest connected flight price is: INR {low_cost_price}/-")
updated_sheet = data_manager.update_lowest_price(city["id"],
low_cost_price
)
message = (f"Subject: Low price alert!✈️\n\nOnly ₹{low_cost_price} "
f"to fly from {city_name}({departure_city}) "
f"to {city["city"]}({city["iataCode"]}), "
f"with {low_cost_flight.stops} stop(s) "
f"on {tomorrow.date().strftime("%d-%m-%Y")}.")
flight_alerts = notification.send_email(message=message,
user_email=user_email_list)
if cheapest_flight < sheet_price and cheapest_flight != "N/A":
updated_sheet = data_manager.update_lowest_price(city["id"],
cheapest_flight)
messages = (f"Subject: Low price alert!✈️\n\nOnly ₹{cheapest_flight} "
f"to fly from {city_name}({departure_city}) "
f"to {city["city"]}({city["iataCode"]}), "
f"on {tomorrow.date().strftime("%d-%m-%Y")}.")
flight_alerts = notification.send_notification(message=messages)NotificationManager has two delivery modes — one for a quick personal notification, and one for broadcasting the same deal to every subscribed customer.
class NotificationManager:
"""Send low-price flight alerts using the configured Gmail SMTP account."""
def __init__(self):
"""Store the Gmail credentials used by all outgoing email alerts."""
self.email = EMAIL
self.password = PASSWORD
def send_notification(self, message):
"""Send a single message to the configured sender address."""
with smtplib.SMTP("smtp.gmail.com") as connection:
connection.starttls()
connection.login(user=self.email, password=self.password)
connection.sendmail(
from_addr=self.email,
to_addrs=self.email,
msg=message.encode("utf-8"),
)
def send_email(self, message, user_email):
"""Send one alert to each email address in a user list."""
with smtplib.SMTP("smtp.gmail.com") as connection:
connection.starttls()
connection.login(user=self.email, password=self.password)
for email in user_email:
connection.sendmail(
from_addr=self.email,
to_addrs=email,
msg=message.encode("utf-8"),
)- Orchestrating multiple independent modules (data, search, notification) into one coherent workflow
- Using a spreadsheet as a lightweight, no-setup database via a REST API wrapper (Sheety)
- Integrating a third-party search API (SerpApi) and parsing deeply nested JSON results
- Implementing fallback logic (direct → indirect search) for a more resilient user experience
- Batch-sending personalized notifications to a list of subscribers
- Reducing redundant API calls with HTTP response caching
- Writing self-documenting code with module-, class-, and method-level docstrings
🐞 This is a learning project, so there may still be bugs and rough edges — feedback and fixes are welcome.
- Tighten error handling so failed API requests are caught cleanly and don't crash the run
- Make the price comparison in
main.pysafe when no direct flight is found - Pull every flight detail (departure, date, stops) from the same itinerary as the price
- Email about indirect flights only when the price is genuinely lower than the stored one
- Add return-flight search, retry logic for transient API failures, and logging instead of
print()
VISHAL YADAV
- GitHub: @VISHAL108-Mech
- LinkedIn: vishal-yadav-2a91a7428
- Email: vy4122000@gmail.com