

In Selenium, find_element locates the first web element that matches a locator and returns a single WebElement you can act on. The modern, cross-language syntax passes a By strategy and a value, for example driver.find_element(By.ID, "login"). Its plural counterpart, find_elements, returns a list of every match, or an empty list when nothing matches.
Key takeaways
find_element(By.X, "value") is the Selenium 4 syntax. The old find_element_by_* helpers were deprecated and then removed in Selenium 4.3.0, so they fail in current releases.
Selenium exposes eight locator strategies through the By class: ID, name, class name, tag name, link text, partial link text, CSS selector, and XPath.
find_element raises NoSuchElementException when nothing matches. find_elements returns an empty list, so it's safe to loop over.
Most "element not found" errors are timing, not a wrong locator. An explicit wait fixes them.
Selenium exposes eight locator strategies through the By class: ID, NAME, CLASS_NAME, TAG_NAME, LINK_TEXT, PARTIAL_LINK_TEXT, CSS_SELECTOR, and XPATH. Each is passed to find_element as driver.find_element(By.STRATEGY, "value"). Since Selenium 4, this unified call replaces the older find_element_by_* helpers, which are deprecated and removed in current releases.
Every example below runs against Selenium's own public web form test page. It's built for practice, so you can run the code as is. We tested each snippet at Proxy-Cheap with Selenium 4.50.0, Python 3.13, and Chromium 141.
Start with one import and one driver. You only need to import By once per script:
python from selenium import webdriver from selenium.webdriver.common.by import By driver = webdriver.Chrome() driver.get("https://www.selenium.dev/selenium/web/web-form.html")
Copying code from an older tutorial? The old style fails on the first call:
python driver.find_element_by_id("my-text-id") # AttributeError: 'WebDriver' object has no attribute 'find_element_by_id'
Selenium 4.0 marked the find_element_by_* and find_elements_by_* helpers as deprecated. Version 4.3.0 removed them. Plenty of older tutorials still show the old style, so swap each one for find_element(By.X, "value").
python text_box = driver.find_element(By.ID, "my-text-id") text_box.send_keys("Selenium 4")
IDs should be unique on a page, so this is the most direct locator you have. Use it whenever the element has a stable ID.
python password = driver.find_element(By.NAME, "my-password")
Form fields almost always carry a name attribute, because the browser sends it when the form is submitted, so By.NAME is a solid pick for inputs.
python dropdown = driver.find_element(By.CLASS_NAME, "form-select")
By.CLASS_NAME takes one class only. Passing "btn btn-outline-primary" raises InvalidSelectorException: Compound class names are not allowed. For several classes, switch to a CSS selector such as .btn.btn-outline-primary.
python heading = driver.find_element(By.TAG_NAME, "h1") print(heading.text) # Web form
Tag names are rarely unique, so find_element gives you the first one on the page. It works best for one-off tags like h1, or inside a parent element you already found.
python link = driver.find_element(By.LINK_TEXT, "Return to index")
This matches the visible text of an <a> element exactly. It only works on <a> elements, so it can't find buttons or inputs.
python same_link = driver.find_element(By.PARTIAL_LINK_TEXT, "Return") print(link == same_link) # True
Partial link text matches any link whose text contains the value. It's handy when link text includes a changing number or date.
python submit = driver.find_element(By.CSS_SELECTOR, "button[type='submit']") custom = driver.find_element(By.CSS_SELECTOR, "input[myprop='myvalue']")
A CSS selector can mix an ID or class with any attribute, and it can step into child elements, all in one short string. That range makes it the everyday default.
python checkbox = driver.find_element(By.XPATH, "//input[@id='my-check-2']") submit = driver.find_element(By.XPATH, "//button[text()='Submit']")
XPath can match by visible text and move up or across the DOM in ways plain CSS selectors can't. That power comes with longer, more fragile expressions, so save it for the cases that need it.
For the full method list in every language, see the official Selenium documentation on finding elements.
find_element returns the first matching element and raises NoSuchElementException when none is found, which makes it right for a single known element. find_elements returns a list of all matches, and an empty list when none is found, so it suits iterating over repeated items such as rows, cards, or search results.
| find_element | find_elements | |
|---|---|---|
| Return type | One WebElement | A list of WebElement objects |
| When nothing matches | Raises NoSuchElementException | Returns an empty list [] |
| Several matches | Returns the first one in DOM order | Returns all of them, in DOM order |
| Best for | One known element, like a login button | Repeated items, and existence checks |
| Performance note | Fails fast with no wait set | With an implicit wait set, an empty result waits the full timeout |
Because an empty list is falsy in Python, find_elements doubles as a safe existence check:
python options = driver.find_elements(By.CSS_SELECTOR, "select[name='my-select'] option") print(len(options)) # 4 if driver.find_elements(By.CSS_SELECTOR, ".alert"): print("Alert found") else: print("No alert on the page") # no exception, no try/except
Use this pattern for optional elements, such as a cookie banner that only shows up sometimes. Wrapping find_element in try/except works too, but the list check is shorter and easier to read.
Prefer a stable, unique ID when one exists, because it is among the fastest and least brittle locators. Fall back to a CSS selector for compact, readable targeting of classes and attributes, and reserve XPath for cases that need text matching, DOM axes, or traversal up the tree. Avoid locators tied to volatile layout or auto-generated class names.
Here's the order we use, and why:
ID first. A unique ID points at one element and rarely changes with the design. In Python, Selenium turns By.ID into a CSS selector before sending it to the browser, so it runs at CSS speed.
CSS selector second. It covers classes and attributes and can step into child elements, all in one compact string. Name and class name lookups are converted to CSS in the same way. The CSS selectors (MDN) guide covers the syntax.
XPath third. Pick it when you need something CSS can't do.
XPath is the right tool in three common cases:
Matching visible text: //button[text()='Submit'] or //label[contains(., 'Textarea')].
Moving sideways: //label[text()='Email']/following-sibling::input finds the field next to a label.
Moving up the tree: //input[@id='my-text-id']/ancestor::form returns the form that holds the input.
Before you commit to a locator, run it through this brittle-locator checklist:
Auto-generated IDs or classes. Values like input-4821 or css-1x2ab9 often change on every build.
Deep positional XPath. /html/body/div[2]/div/div[3]/span breaks as soon as a wrapper div is added.
Index-based selectors. :nth-child(4) or (//a)[7] depend on page order, not meaning.
Layout classes. Names like col-md-4 or mt-3 describe styling, which changes with redesigns.
Full text that changes. Prices and dates make poor exact matches, since they change all the time.
Stable locators matter most on jobs you run every day. If you run SEO research workflows that read the same results layout across many markets, one fragile selector can quietly empty a whole report.
In our tests, locator strategies took 4.5 to 4.9 ms per call on a small form, with link text slightly slower. On a 5,000-row page, CSS and ID stayed near 4.5 ms, XPath reached 8.2 ms, and link text about 182 ms. For a few elements, the difference is negligible, but repeated lookups add up.
Test setup. Each strategy ran 1,000 times after 50 warm-ups, with three randomized rounds. We used Selenium 4.50.0, Python 3.13, headless Chromium 141, and ChromeDriver 141 on a 2 vCPU, 8 GB Linux machine. Pages were served locally, eliminating network effects.
| Locator | Web form (ms) | 5,000-row page (ms) |
|---|---|---|
| By.CSS_SELECTOR | 4.57 | 4.41 |
| By.ID | 4.63 | 4.52 |
| By.CLASS_NAME | 4.63 | 4.78 |
| By.NAME | 4.53 | 4.88 |
| By.TAG_NAME | 4.54 | Not measured |
| By.XPATH | 4.63 | 8.15 |
| By.LINK_TEXT | 4.79 | 181.54 |
| By.PARTIAL_LINK_TEXT | 4.85 | 181.90 |
Tag name was excluded from the large-page test because input matched the first row instead of the target.
On small pages, most of the 4.5 ms comes from the Python-to-ChromeDriver round trip, so strategy barely matters. On large pages, XPath was about 1.8x slower than CSS, while link text was about 41x slower because it scans link text across the page. For repeated lookups, prefer CSS or ID.
A NoSuchElementException usually means the element was not in the DOM yet when find_element ran, not that it is missing. Replace fixed sleeps with an explicit wait: WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.CSS_SELECTOR, ".result"))). This polls until the element appears or the timeout is reached, which keeps tests reliable on content that loads after the initial page render.
Selenium's dynamic test page shows the problem clearly. Clicking "Add a box!" inserts a new element about one second later:
python 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 from selenium.common.exceptions import NoSuchElementException driver = webdriver.Chrome() driver.get("https://www.selenium.dev/selenium/web/dynamic.html") driver.find_element(By.ID, "adder").click() # the box appears about 1 second later try: driver.find_element(By.ID, "box0") except NoSuchElementException: print("Too early: box0 is not in the DOM yet") wait = WebDriverWait(driver, 10) box = wait.until(EC.presence_of_element_located((By.ID, "box0"))) print("Found:", box.get_attribute("class")) # redbox driver.find_element(By.ID, "reveal").click() revealed = wait.until(EC.visibility_of_element_located((By.ID, "revealed"))) revealed.send_keys("visible now") button = wait.until(EC.element_to_be_clickable((By.ID, "adder"))) button.click() driver.quit()
The locator was right all along. It just ran a second too early. Pick the condition that matches what you do next:
presence_of_element_located: the element is in the DOM. Enough for reading attributes.
visibility_of_element_located: the element is in the DOM and shown on screen. Use it before reading .text or typing.
element_to_be_clickable: the element is visible and enabled. Use it before .click().
A fixed time.sleep(3) wastes time on fast pages and can still fail when a page takes longer. Explicit waits return as soon as the condition is met.
Don't mix implicit and explicit waits. Selenium's explicit wait documentation warns this can cause unpredictable timeouts. Keep implicit waits at zero and use explicit waits instead.
StaleElementReferenceException means the DOM changed after you found the element. Find it again after the update.
Network conditions also affect load times, so allow some timeout headroom. For long sessions needing a consistent route, static and rotating ISP proxies can keep the connection stable while explicit waits handle page timing.
Call find_element on a WebElement rather than the driver to search only within that element's subtree, for example row.find_element(By.TAG_NAME, "td"). Selenium 4 also adds relative locators, so you can target an element with locate_with(...).below(), .above(), .to_left_of(), .to_right_of(), or .near() relative to a known anchor element.
Nested finding works on any element you already have. The search starts at that element and only looks inside it:
python from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.relative_locator import locate_with driver = webdriver.Chrome() driver.get("https://www.selenium.dev/selenium/web/web-form.html") form = driver.find_element(By.TAG_NAME, "form") select = form.find_element(By.NAME, "my-select") # searches inside the form only for label in driver.find_elements(By.CSS_SELECTOR, "label.form-check-label"): box = label.find_element(By.TAG_NAME, "input") print(label.text, box.get_attribute("type"), box.is_selected()) # Checked checkbox checkbox True # Default checkbox checkbox False # Checked radio radio True # Default radio radio False
That loop is the standard pattern for any repeated structure. Say you're collecting a comparison table of different proxy types. Use find_elements(By.CSS_SELECTOR, "table tr") for the rows, then row.find_elements(By.TAG_NAME, "td") for each row's cells. Each lookup stays inside its row, so cells never get mixed up between rows.
The official docs add one caveat. Every nested call is a separate command to the browser. When you only need the final element, one selector like form select[name='my-select'] does the job in a single call.
Relative locators find an element by its position on screen. In Python you build them with locate_with:
python text_box = driver.find_element(By.ID, "my-text-id") below = driver.find_element(locate_with(By.TAG_NAME, "input").below(text_box)) print(below.get_attribute("name")) # my-password near = driver.find_element( locate_with(By.TAG_NAME, "button").near({By.ID: "my-radio-2"}, 100) ) print(near.text) # Submit driver.quit()
The anchor can be a WebElement or a locator dict like {By.ID: "my-radio-2"}. By default, near() looks within 50 pixels. We passed 100 because the Submit button sits a little further away.
Relative locators read the rendered layout, so the result can change with window size. Use them when an element has no good attribute of its own. The Selenium relative locators reference lists every direction.
For thousands of pages or market-specific results, route each browser session through a proxy matching the target region. Residential and datacenter proxies support scalable collection of public data for localization QA and performance testing.
One server IP can trigger rate limits and HTTP 429s at scale, while its location may return the wrong regional prices or language. A proxy per session solves both issues.
Chrome accepts a proxy address as a flag, but not credentials. Selenium 4 can handle proxy authentication through WebDriver BiDi, so the unmaintained selenium-wire package is no longer needed.
python import csv 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 from selenium.common.exceptions import TimeoutException # One proxy endpoint per market. Copy host, port, and login from your dashboard. MARKETS = { "us": {"server": "http://your-us-proxy-host:port", "user": "your-username", "password": "your-password"}, "de": {"server": "http://your-de-proxy-host:port", "user": "your-username", "password": "your-password"}, } PAGES = [ "https://www.selenium.dev/selenium/web/web-form.html", "https://www.selenium.dev/selenium/web/relative_locators.html", ] TARGET = (By.TAG_NAME, "h1") def make_driver(proxy): options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument(f"--proxy-server={proxy['server']}") options.enable_bidi = True # lets Selenium answer the proxy login driver = webdriver.Chrome(options=options) driver.network.add_auth_handler(proxy["user"], proxy["password"]) return driver def read_element(driver, url): # Navigate over BiDi so the auth handler can respond to the proxy driver.browsing_context.navigate( context=driver.current_window_handle, url=url, wait="complete" ) try: element = WebDriverWait(driver, 15).until(EC.presence_of_element_located(TARGET)) return element.text except TimeoutException: return None # log it and move on instead of stopping the run rows = [] for market, proxy in MARKETS.items(): driver = make_driver(proxy) try: for url in PAGES: rows.append({"market": market, "url": url, "h1": read_element(driver, url)}) finally: driver.quit() with open("results.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["market", "url", "h1"]) writer.writeheader() writer.writerows(rows)
One testing detail matters: with the auth handler enabled, driver.get() stalled until the page-load timeout. driver.browsing_context.navigate() kept navigation on BiDi and loaded pages through our password-protected proxy. With IP whitelist authentication, skip the handler and use only --proxy-server.
The TimeoutException fallback lets long jobs continue by logging None for slow pages, while finally ensures Chrome closes. See our large-scale data collection guide for more.
Choose a proxy by workload:
Location-accurate data: rotating residential proxies offer 180+ locations, country/city targeting, rotation or sticky sessions, and pay-as-you-go GB billing. Static residential (ISP) IPs suit fixed sessions.
High-throughput public pages: datacenter IPv4/IPv6 offer unlimited bandwidth and dedicated IPs.
Large jobs: unmetered SOCKS5 cover 195+ locations without data caps.
Use the Proxy-Cheap API docs to manage proxies and IPs from code.
Proxy-Cheap uses pay-as-you-go pricing with no monthly commitment, so you can test a small plan with your Selenium job first.
Install Selenium with pip. Current releases need Python 3.10 or later:
bash pip install selenium
You don't need to download ChromeDriver yourself. Since Selenium 4.6, Selenium Manager checks your Chrome version and fetches the matching driver the first time you start a session. Older guides that install webdriver-manager or set a driver path are out of date.
Here's a minimal end-to-end script. It opens a page and prints the text of one element, then closes the browser:
python from selenium import webdriver from selenium.webdriver.common.by import By options = webdriver.ChromeOptions() options.add_argument("--headless=new") # remove this line to watch the browser driver = webdriver.Chrome(options=options) try: driver.get("https://www.selenium.dev/selenium/web/web-form.html") heading = driver.find_element(By.TAG_NAME, "h1") print(heading.text) # Web form finally: driver.quit()
The By class lives in selenium.webdriver.common.by. Its eight attributes are plain strings, such as By.ID "id" and By.CSS_SELECTOR "css selector". That's why find_element("id", "my-text-id") also works, but the By constants are easier to read and harder to mistype.
If you test pages by hand across regions without code, the Chrome proxy extension sets up a proxy in the browser with a few clicks. Developers who want to manage proxies programmatically can read our explainer on the proxy API. For long tests that need the same IP each day, fixed residential IPs keep one address for the whole subscription.