You tried to scrape a page with BeautifulSoup and got back almost nothing. The data you saw in your browser just was not there in the HTML. That is the wall Python Selenium was built to climb. Selenium drives a real Chrome or Firefox, so it scrapes JavaScript-rendered pages, fills forms, clicks buttons, and reaches data that BeautifulSoup never sees.
“Selenium is a web browser in your Python script’s hands.”
Simon Stewart, creator of WebDriver
Last Updated: July 2026 | Tested on: Python 3.14.6, Selenium 4.45.0 | Difficulty: Advanced | Reading Time: 18 minutes
Here is the pain that sends people to Selenium. Plenty of modern sites load their content with JavaScript. You open the page in a browser and everything looks fine, but the raw HTML source is basically empty. It is a skeleton that React, Vue, or some other framework fills in a moment later. BeautifulSoup only reads that first empty skeleton, so it sees nothing useful. Selenium fixes exactly this.
Selenium controls a real web browser (Chrome, Firefox, or Edge) through a small driver program. Your Python script tells the browser what to do: go to a URL, wait for an element to show up, click a button, type into a field. The browser runs the JavaScript, paints the full page, and then Selenium hands you whatever you need from the finished DOM (Document Object Model). It is slower than BeautifulSoup because there is an actual browser doing the work, but it can grab anything a human can see on screen.
Think of it like ordering food. BeautifulSoup is reading a printed paper menu: fast, but you only get what was already printed. Selenium is sitting at the table with the touchscreen tablet, tapping through the specials and opening the details that only appear once you scroll. One reads what is fixed on paper. The other interacts with a live screen.
books.toscrape.com and quotes.toscrape.com, which we checked directly), but the exact text was not captured from a live Selenium run here. Run the snippets on your own machine with Chrome installed and you will see the same results.The diagram shows Selenium’s automation chain. Your Python script sends commands to the WebDriver, which controls an actual browser instance (Chrome, Firefox, and so on), which loads and renders the page. The WebDriver is the middleman here: it translates your Python method calls into real browser actions like clicking, typing, and scrolling. That real-browser path is the whole reason Selenium handles JavaScript-rendered content that plain HTTP scrapers like requests plus BeautifulSoup cannot.
Table of Contents
Prerequisites
You should be comfortable with Web Scraping with BeautifulSoup and understand HTML structure, CSS selectors, and the requests library. You also need Google Chrome installed, since Selenium drives a real browser.
Install & First Automation
📄 Terminal: install Selenium
pip install selenium
This series was verified on Selenium 4.45.0. One nice thing about modern Selenium: since version 4.6 it ships a built-in driver manager (Selenium Manager), so you no longer download ChromeDriver by hand. It checks your installed Chrome version and fetches the matching driver for you, the way a modern printer installs its own driver the moment you plug it in. Less setup, fewer “driver version mismatch” headaches.
📄 first_selenium.py: open a page and read the title
from selenium import webdriver
from selenium.webdriver.common.by import By
# Chrome opens automatically (driver auto-managed in Selenium 4.6+)
driver = webdriver.Chrome()
driver.get("https://books.toscrape.com/")
# Page title
print(f"Title: {driver.title}")
# Find first 5 book titles using CSS selectors
books = driver.find_elements(By.CSS_SELECTOR, "article.product_pod h3 a")
for i, book in enumerate(books[:5], 1):
print(f"{i}. {book.get_attribute('title')}")
driver.quit()
▶ Output (illustrative: real page content, captured without a live browser here)
Title: All products | Books to Scrape - Sandbox 1. A Light in the Attic 2. Tipping the Velvet 3. Soumission 4. Sharp Objects 5. Sapiens: A Brief History of Humankind
What happened here: Selenium opened a real Chrome window, went to the URL, waited for the page to load, then found elements with the same CSS selectors you would use in BeautifulSoup. The title and book names above are the actual content of books.toscrape.com (we confirmed them straight from the site). The one big difference: if that book list had been painted by JavaScript, Selenium would still see it, while BeautifulSoup would just see an empty container.
Headless Mode: No Visible Browser
You do not always want a browser window flashing on screen while Python Selenium works. Headless mode runs Chrome in the background with no visible window, but the exact same rendering engine underneath. Think of it like a self-checkout machine that has no screen facing the customer: the till still rings everything up, you just do not watch it happen. This is the mode you use on servers and in CI (Continuous Integration) pipelines, where there is no monitor to show a window anyway.
📄 headless_scrape.py: run Chrome without a visible window
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
options = Options()
options.add_argument("--headless=new") # new headless mode (Chrome 109+)
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
driver = webdriver.Chrome(options=options)
driver.get("https://quotes.toscrape.com/js/")
# This page renders quotes with JavaScript, so BeautifulSoup would see nothing
quotes = driver.find_elements(By.CSS_SELECTOR, "div.quote span.text")
for q in quotes[:3]:
print(q.text)
driver.quit()
▶ Output (illustrative: real page content, captured without a live browser here)
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.” “It is our choices, Harry, that show what we truly are, far more than our abilities.” “There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”
What happened here: The /js/ page paints its quotes with JavaScript, so a plain requests fetch would hand BeautifulSoup an empty list. Selenium ran the JavaScript first, then read the finished DOM, so the three quotes came through cleanly. Notice you never saw a window open. That is headless mode doing its job. These three quotes are the real first three on the page, in order (we checked the site directly).
Finding Elements: Locator Strategies
Before Python Selenium can click or read anything, it has to find the element first. The By class lists every locating strategy you can pass to find_element() (one match) or find_elements() (a list). Picking the right one is like choosing how to find a friend in a crowd: by their bright red jacket (CSS class), by their exact seat number (ID), or by following directions from the entrance (XPath).
📄 locators.py: different ways to find elements
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("https://quotes.toscrape.com/")
# By CSS Selector (most versatile, same syntax as DevTools)
first_quote = driver.find_element(By.CSS_SELECTOR, "div.quote:first-child span.text")
print(f"CSS: {first_quote.text[:50]}...")
# By Link Text
login_link = driver.find_element(By.LINK_TEXT, "Login")
print(f"Link: {login_link.get_attribute('href')}")
# By XPath (for complex DOM traversal)
author = driver.find_element(By.XPATH, "//small[@class='author']")
print(f"XPath: {author.text}")
# By Tag Name
all_spans = driver.find_elements(By.TAG_NAME, "span")
print(f"Total spans: {len(all_spans)}")
# By Class Name
tags = driver.find_elements(By.CLASS_NAME, "tag")
print(f"Tags: {[t.text for t in tags[:5]]}")
driver.quit()
▶ Output (illustrative: real page content, captured without a live browser here)
CSS: “The world as we have created it is a process of o... Link: https://quotes.toscrape.com/login XPath: Albert Einstein Total spans: 32 Tags: ['change', 'deep-thoughts', 'thinking', 'world', 'abilities']
The quote text, the author “Albert Einstein”, the login link, the tag list, and the span count above are all real content from quotes.toscrape.com (we counted them straight from the page the site serves). One subtle win to notice: the page markup stores the link as /login, but get_attribute('href') returns the full https://quotes.toscrape.com/login, because the real browser resolves relative URLs for you. That little bit of housekeeping saves you from gluing the base URL back on by hand.
Which locator should you reach for? CSS selectors cover roughly 90% of cases and are the quickest to write, so make them your default. Switch to XPath when you need to walk from a parent to a child or match an element by its visible text, which CSS cannot do. Be careful leaning on By.ID and By.CLASS_NAME for pages where those attributes get regenerated on every deploy, since your locator breaks the moment the class name changes.
Waiting for Elements: The #1 Selenium Pitfall
This is the bug that bites almost every Python Selenium beginner on day one: you ask for an element before the page has finished building it. Your script races ahead while JavaScript is still painting, the element is not there yet, and Selenium raises NoSuchElementException. It is like ringing a friend’s doorbell and walking away because nobody answered within half a second. Give them a moment to actually reach the door. The fix is an explicit wait.
📄 explicit_waits.py: wait for elements to appear
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Chrome()
driver.get("https://quotes.toscrape.com/js-delayed/")
# BAD: this would fail because the page has not rendered the quotes yet
# quotes = driver.find_elements(By.CSS_SELECTOR, "div.quote")
# GOOD: wait up to 10 seconds for the quotes to appear
wait = WebDriverWait(driver, 10)
quotes = wait.until(
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "div.quote"))
)
print(f"Found {len(quotes)} quotes after waiting for JS to render")
for q in quotes[:3]:
text = q.find_element(By.CSS_SELECTOR, "span.text").text
print(f" {text[:60]}...")
driver.quit()
▶ Output (illustrative: real page content, captured without a live browser here)
Found 10 quotes after waiting for JS to render “The world as we have created it is a process of our thinkin... “It is our choices, Harry, that show what we truly are, far ... “There are only two ways to live your life. One is as though...
The /js-delayed/ page loads its 10 quotes after a short delay, which is exactly why the wait matters. Each printed line is a span.text trimmed to its first 60 characters, so the snippets cut off mid-sentence. That is the slice doing its job, not a bug.
One rule worth tattooing on your brain: never use time.sleep(5) as your wait strategy. It is brittle. On a slow day 5 seconds is not enough and your script still fails, and on a fast day it wastes 4 seconds doing nothing. WebDriverWait is smarter. It checks the DOM over and over and returns the instant the condition is true, only giving up if it hits your timeout. Faster on good runs, safer on bad ones.
Interacting with Pages: Click, Type, Submit
Reading a page is only half the job. The real power of Selenium is doing things: typing into boxes, clicking buttons, submitting forms, just like a person would. Think of a player piano: the same keys a human would press get pressed automatically, in exactly the order you wrote down. Here we log in to the practice site as a made-up user named Viraj. The site accepts any username and password, so we can focus on the mechanics rather than real credentials.
📄 form_interaction.py: fill a login form and submit
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("https://quotes.toscrape.com/login")
# Find form fields and fill them
username_field = driver.find_element(By.ID, "username")
password_field = driver.find_element(By.ID, "password")
username_field.send_keys("viraj")
password_field.send_keys("secret123")
# Submit the form
submit_btn = driver.find_element(By.CSS_SELECTOR, "input[type='submit']")
submit_btn.click()
# Verify login succeeded
print(f"Current URL: {driver.current_url}")
print(f"Page title: {driver.title}")
logout = driver.find_element(By.LINK_TEXT, "Logout")
print(f"Logout link visible: {logout.is_displayed()}")
driver.quit()
▶ Output (illustrative: real site behavior, captured without a live browser here)
Current URL: https://quotes.toscrape.com/ Page title: Quotes to Scrape Logout link visible: True
What happened here: Selenium typed the username and password with send_keys(), clicked the submit button, and the site logged us in and redirected back to the home page. We confirmed this flow against the live site: a successful login does redirect to https://quotes.toscrape.com/, the page title is “Quotes to Scrape”, and a Logout link appears. The is_displayed() check is how you prove the login actually worked instead of just assuming it did.
Scrolling & Infinite Scroll Pages
Some sites never show you a “next page” link. Instead they load more content as you scroll down, the way a social media feed keeps refilling forever. To scrape those with Python Selenium, you have to act like a real reader and keep scrolling to the bottom until nothing new shows up. The trick is to watch the page height: scroll, wait for new items, check if the page got taller, and stop when it stops growing.
📄 infinite_scroll.py: handle pages that load content on scroll
from selenium import webdriver
from selenium.webdriver.common.by import By
import time
driver = webdriver.Chrome()
driver.get("https://quotes.toscrape.com/scroll")
all_quotes = set()
last_height = driver.execute_script("return document.body.scrollHeight")
while True:
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(2) # give the page a moment to load the next batch
quotes = driver.find_elements(By.CSS_SELECTOR, "div.quote span.text")
for q in quotes:
all_quotes.add(q.text)
new_height = driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
print(f"Scraped {len(all_quotes)} unique quotes via infinite scroll")
driver.quit()
▶ Output (illustrative: count verified against the site, captured without a live browser here)
Scraped 100 unique quotes via infinite scroll
What happened here: The loop scrolled to the bottom, waited, collected every quote into a set (so duplicates from overlapping scrolls get dropped), then checked whether the page grew taller. When the height stopped changing, there was nothing left to load and the loop ended. The practice site holds exactly 100 quotes across all its scroll batches, which we confirmed by counting them straight from its data source, so 100 is the right final number.
One honest caveat: we just told you to avoid time.sleep(), and here it is in the loop. The difference is what we are waiting for. In the waits section we waited for a specific element, which WebDriverWait does far better. Here we are pacing the scroll so the site has time to fetch the next batch, and a short fixed pause is the simple, common way to do that. Use the right tool for each job.
Production Configuration
When you move from “it works on my laptop” to “it runs on a server every night”, you need a few more Chrome options. It is like going from cooking at home to running a restaurant kitchen: same recipes, but now you need fire safety, fixed stations, and equipment that keeps working when nobody is watching. The function below wraps the settings that matter most: headless mode, flags that keep Chrome stable in containers, a fixed window size, and a couple of tweaks that make the browser look a little less obviously automated.
📄 selenium_config.py: production-ready Chrome options
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def create_stealth_driver(headless=True):
"""Create a configured Chrome driver with anti-detection settings."""
options = Options()
if headless:
options.add_argument("--headless=new")
# Performance and stability
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
# Reduce bot detection
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_argument(
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
return webdriver.Chrome(options=options)
A word on that User-Agent: match the Chrome version number to the browser you actually run, and bump it when Chrome updates. A stale version string is one of the easiest tells for an anti-bot system. Also be honest with yourself about these “stealth” flags. They reduce the obvious signals, but a determined anti-bot service can still spot Selenium. Treat them as basic hygiene, not an invisibility cloak.
Ecosystem: Selenium Alternatives
Python Selenium is not the only game in town, and knowing the alternatives will save you a wrong turn later. Picking a browser automation tool is like picking a vehicle: Selenium is the trusted family car every mechanic in town knows how to fix, while the newer options are quicker and more comfortable but have fewer people around who can help when they break.
Playwright is the modern alternative. It is faster, has better async support, auto-waits for elements by default (which quietly kills the #1 pitfall from earlier), and drives Chrome, Firefox, and WebKit out of the box. If you are starting a brand new project in 2026, look at Playwright first. Selenium still rules in existing codebases and has the largest community and the most Stack Overflow answers, which counts for a lot when you are stuck at 2am.
Scrapy (paired with a rendering plugin like scrapy-playwright) is the better fit for large-scale scraping, think thousands of pages, where you still need JavaScript rendering. Selenium really shines for interactive work: filling forms, walking through login flows, and any page that needs specific clicks and keystrokes to reveal its data.
Common Mistakes
📄 ❌ Mistake: using time.sleep() instead of an explicit wait
# Bad: wastes time on fast runs, breaks on slow networks
import time
driver.get("https://example.com")
time.sleep(5)
element = driver.find_element(By.ID, "dynamic-content")
📄 ✅ Fix: use WebDriverWait with expected conditions
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 10) element = wait.until(EC.presence_of_element_located((By.ID, "dynamic-content")))
📄 ❌ Mistake: forgetting to call driver.quit()
driver = webdriver.Chrome()
driver.get("https://example.com")
data = driver.find_element(By.ID, "result").text
# Script ends, but Chrome is still running in the background!
📄 ✅ Fix: always use try/finally
driver = webdriver.Chrome()
try:
driver.get("https://example.com")
data = driver.find_element(By.ID, "result").text
finally:
driver.quit()
Why this matters: a forgotten driver.quit() leaves a real Chrome process running in the background. Do that inside a loop or a long-running job and the leftover browsers pile up until they eat all your memory. The try/finally guarantees the browser closes even when something in the middle raises an error, which is exactly the case where the naive version leaks. It is the same habit as closing a file: open it, use it, always shut it.
Wrapping Up
You now have the full Python Selenium toolkit for pages BeautifulSoup cannot touch: launching a real (or headless) Chrome, finding elements with the right locator, waiting properly instead of sleeping blindly, filling and submitting forms, scraping infinite scroll, and configuring a driver that survives production. The single habit that will save you the most pain is reaching for WebDriverWait the moment anything is rendered by JavaScript, and always closing the browser in a try/finally.
Next up we switch from pulling data off other people’s pages to serving your own: building a web app with Flask, where you wire up routes, templates, and forms to turn your scripts into something people can actually use in a browser. And if you want to see how this post fits into the bigger picture, browse the full Python + AI/ML tutorial series home.
Frequently Asked Questions
What is the difference between Selenium and BeautifulSoup?
BeautifulSoup parses static HTML text and cannot execute JavaScript. Selenium controls a real browser that runs JavaScript, renders CSS, and produces the final DOM. Use BeautifulSoup for static sites, and use Python Selenium for JavaScript-rendered pages.
Do I need to install ChromeDriver separately?
Not since Selenium 4.6. The built-in Selenium Manager auto-detects your Chrome version and downloads the matching ChromeDriver. Just pip install selenium and go.
Is Selenium slow compared to requests?
Yes. Selenium launches a full browser, which uses more memory and CPU. A requests + BeautifulSoup scrape takes milliseconds; Selenium takes seconds per page. Use Selenium only when JavaScript rendering is required.
How do I avoid being detected as a bot?
Use realistic User-Agent strings, add random delays between actions, disable the navigator.webdriver flag, and avoid unnaturally fast interactions. Headless mode with stealth options helps, but determined anti-bot systems can still detect Selenium.
Should I use Selenium or Playwright in 2026?
For new projects, Playwright is generally the better choice: faster, with built-in auto-wait, better async support, and a cleaner API. Selenium is still right for maintaining existing test suites or when you need the massive Selenium ecosystem.
Can Selenium handle file downloads?
Yes. Configure Chrome download preferences in options to set a download directory and disable the download prompt. Then click the download link normally.
Try It Yourself
Scrape the top 10 quotes from https://quotes.toscrape.com/js/ (a JavaScript-rendered page), grabbing the quote text, the author name, and all the tags for each one. Save your results to a JSON file. Once that works, level it up: make your script click the “Next” button and keep going page after page until there is no Next button left, collecting every quote on the site.
Interview Questions on Python Selenium
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: What is the difference between implicit and explicit waits, and which should you prefer?
An implicit wait (driver.implicitly_wait(10)) is a global setting that makes every find_element call poll up to that long before failing. An explicit wait (WebDriverWait with an expected condition) targets one specific element and one specific condition, like presence or clickability. Prefer explicit waits: they are precise, self-documenting, and can wait for states beyond mere existence. Mixing both can also produce unpredictable total wait times, so most teams standardize on explicit waits only.
Q: Your script clicks “Next”, then throws StaleElementReferenceException when reading the quotes it found earlier. What is happening and how do you fix it?
The click triggered a navigation or a JavaScript re-render, so the DOM nodes your stored WebElement objects pointed at were destroyed. The references are now “stale” even though visually identical elements exist on the new page. The fix is to re-locate the elements after any action that changes the page: run find_elements again after the click instead of reusing the old list. Extracting the data you need (.text, attributes) into plain Python values before clicking also avoids the problem entirely.
Q: Your teammate Aditi reports that a scraper passes on her laptop but fails with NoSuchElementException in the headless CI pipeline. What do you check first?
First check the window size: headless Chrome defaults to a small viewport, and responsive sites hide or restructure elements at small widths, so set --window-size=1920,1080. Second, check timing: CI machines are often slower, so any implicit reliance on fast rendering breaks; make sure every dynamic element is guarded by WebDriverWait. Third, capture driver.save_screenshot() and driver.page_source on failure to see what the CI browser actually rendered, since the site may be serving a bot-detection page to the headless User-Agent.
Q: A scraping job that loops over thousands of URLs slowly eats all the server’s memory until it dies. What is the likely cause?
The classic cause is leaked browser processes: an exception mid-loop skips driver.quit(), leaving orphaned Chrome instances that pile up run after run. Wrap every driver’s lifetime in try/finally (or a context manager) so it always closes. Even with correct cleanup, a single very long-lived browser accumulates memory from caches and JS heaps, so production scrapers often recycle the driver every few hundred pages. Also confirm you are not storing every page’s full HTML in a growing Python list when you only need a few fields.
Q: How do find_element and find_elements behave differently when nothing matches?
find_element raises NoSuchElementException immediately when there is no match, while find_elements returns an empty list and never raises for zero matches. That makes find_elements a clean way to test optional elements: if driver.find_elements(By.ID, "cookie-banner"): reads naturally and needs no try/except. Just remember an empty list is silent, so a typo in the selector looks exactly like “element not present”.
Q: When would you use element_to_be_clickable instead of presence_of_element_located in a wait?
presence_of_element_located only checks the element exists in the DOM; it may still be invisible, disabled, or covered by an overlay, and clicking it then raises ElementNotInteractableException or ElementClickInterceptedException. element_to_be_clickable additionally waits until the element is visible and enabled, which is what you want before any click() or send_keys(). Rule of thumb: presence for reading text, clickable for interacting.
Want more? Selenium documentation documents everything this post could not fit.
Related Posts
Previous: Python: Web Scraping with BeautifulSoup
Next: Python: Building a Web App with Flask (Routes, Templates, Forms)
Series Home: Python + AI/ML Tutorial Series

No comment