Web Scraping With Python: Step-by-Step Tutorial
To scrape a website with Python, fetch the page with Requests, parse the HTML with Beautiful Soup, select the elements you need with CSS selectors, follow the "next page" links, and save the rows to CSV, pausing between requests. The full scraper below does this in about 50 lines, runs on Python 3.10 or newer and saves 1,000 books from 50 pages in under 2 minutes. You do not need to learn HTML and CSS first: reading tags, the class, id and href attributes and how elements nest is enough, and this tutorial uses only 4 CSS selectors, which you pick up as you go. When the scraper outgrows one IP, ProxyField residential proxies plug into Requests with one argument from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 14 sections
- What you build in this tutorial
- Step 1. Set up the environment
- Step 2. Inspect the page
- Step 3. Fetch the page with Requests
- Step 4. Parse the HTML with Beautiful Soup
- Step 5. Extract the fields
- Step 6. Follow pagination
- Step 7. Save the data to CSV
- Step 8. Handle errors, retries and robots.txt
- Step 9. Pages that load data with JavaScript
- The complete scraper
- Scaling up: when a script is not enough
- Add a proxy when the site limits your IP
- Web Scraping With Python FAQ
What you build in this tutorial
A scraper for books.toscrape.com, a practice site made for learning to scrape, that collects the title, price, rating and stock of every book across all catalog pages and writes them to books.csv. The same steps work for any site whose data is in the HTML.
What you need:
- Python 3.10 or newer (the current Requests, 2.34.2, requires it), a terminal, and 20 minutes. Versions on PyPI: Requests 2.34.2, Beautiful Soup 4.15.0.
- Enough HTML to read one card. This is one book on the page, trimmed:
<article class="product_pod">
<p class="star-rating Three"> … </p>
<h3><a href="catalogue/a-light-in-the-attic_1000/index.html" title="A Light in the Attic">A Light in the ...</a></h3>
<p class="price_color">£51.77</p>
<p class="instock availability"> In stock </p>
</article>These are the 4 CSS selectors that read it, the only CSS this tutorial needs:
| Selector | Reads | Meaning |
|---|---|---|
article.product_pod | each book card | an <article> with the class product_pod |
h3 a | the title and link | an <a> inside an <h3> |
p.price_color | the price | a <p> with the class price_color |
li.next a | the next-page link | an <a> inside <li class="next"> |
CSS for scraping means selecting elements, not styling pages. What else a first scraper needs, from Python basics to a proxy: web scraping requirements.
Step 1. Set up the environment
Create a virtual environment so the scraper's packages stay separate from the rest of your system, then install the two libraries.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install requests beautifulsoup4Step 2. Inspect the page
Open the page in your browser, right-click a book title and choose Inspect. You will see each book in an <article class="product_pod">, the title in the title attribute of an <a> inside <h3>, the price in <p class="price_color"> and the rating as a class on <p class="star-rating Three">. Write these down: they become your selectors. This is also how you learn HTML for any other site: inspect the element that holds your data and read its tag and class.
Then press Ctrl+U (Cmd+Option+U on a Mac) to view the page source. If the data you want is in the source, Requests will get it. If it is not, the page builds its content with JavaScript, and you need the page's JSON requests or a browser (step 9).
Step 3. Fetch the page with Requests
import requests
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; books-tutorial/1.0; [email protected])"}
r = requests.get("https://books.toscrape.com/", headers=HEADERS, timeout=30)
r.encoding = "utf-8" # the site sends no charset; without this "£" reads as "£"
print(r.status_code, len(r.text)) # 200 and the size of the HTML
r.raise_for_status() # stop on 4xx and 5xx errorsAlways set a timeout, or a stuck request can hang your script forever. A descriptive User-Agent with a contact tells the site who you are; headers in more depth: web scraping headers.
Step 4. Parse the HTML with Beautiful Soup
from bs4 import BeautifulSoup
soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.get_text(strip=True))
books = soup.select("article.product_pod")
print(len(books)) # 20 books per pageselect() takes CSS selectors and returns a list; select_one() returns the first match or None. Beautiful Soup's own guide covers every method: Beautiful Soup web scraping.
Step 5. Extract the fields
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def parse_book(card):
link = card.select_one("h3 a")
rating = card.select_one("p.star-rating")["class"][1]
return {
"title": link["title"],
"price": card.select_one("p.price_color").get_text(strip=True).lstrip("£"),
"rating": RATINGS.get(rating),
"in_stock": "In stock" in card.select_one("p.availability").get_text(),
"url": requests.compat.urljoin(r.url, link["href"]),
}
print(parse_book(books[0]))Two habits save hours later: read attributes with tag["attr"] and text with get_text(strip=True), and turn relative links into absolute ones with urljoin. CSS selectors, XPath and extracting links and text in general: HTML scraping.
Step 6. Follow pagination
Each catalog page has a "next" link (li.next a) until the last one. Loop until it disappears, and pause between pages.
import time
url = "https://books.toscrape.com/"
rows = []
while url:
r = requests.get(url, headers=HEADERS, timeout=30)
r.raise_for_status()
r.encoding = "utf-8"
soup = BeautifulSoup(r.text, "html.parser")
rows += [parse_book(c) for c in soup.select("article.product_pod")]
nxt = soup.select_one("li.next a")
url = requests.compat.urljoin(r.url, nxt["href"]) if nxt else None
time.sleep(1) # be polite: one page per second
print(len(rows)) # 1,000 books on 50 pagesPages numbered with ?page=2 work the same way with a counter; infinite scroll usually loads a JSON endpoint you can call with a page or offset parameter.
Step 7. Save the data to CSV
import csv
with open("books.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)For JSON, json.dump(rows, f, ensure_ascii=False, indent=2); for analysis, pandas.DataFrame(rows). To open the result in a spreadsheet or to scrape into Excel directly, see web scraping in Excel.
Step 8. Handle errors, retries and robots.txt
Real sites fail now and then: timeouts, 429 Too Many Requests, 503 errors. Retry a few times with a growing pause, and slow down when the site tells you to.
from requests.adapters import HTTPAdapter, Retry
from urllib.robotparser import RobotFileParser
session = requests.Session()
session.headers.update(HEADERS)
session.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, backoff_factor=2, status_forcelist=[429, 500, 502, 503, 504], respect_retry_after_header=True)))
robots = RobotFileParser("https://books.toscrape.com/robots.txt")
robots.read()
print(robots.can_fetch(HEADERS["User-Agent"], "https://books.toscrape.com/catalogue/page-2.html")) # Truebooks.toscrape.com has no robots.txt (it returns 404), which RFC 9309 treats as "no rules", so can_fetch returns True.
What you see when a request fails, and what the code above does about it:
| Error in your terminal | What it means | What to do |
|---|---|---|
requests. | The server did not answer within timeout | The adapter retries 4 times with a growing pause (backoff_factor=2); if it still fails, check the URL and your network |
requests. | You sent requests faster than the site allows | Raised by raise_for_status() without the retry adapter; with it, the session waits for the Retry-After header and tries again |
requests. | The 429s did not stop after 4 retries | Slow down: raise time.sleep(), or spread requests over several IPs |
requests. | The site refuses your script | Check the User-Agent and the site's rules; do not retry the same request in a loop |
requests. | The proxy rejected the username or password | Check the credentials in the proxy URL |
robots.txt states which paths a site asks crawlers to avoid; it is a request, not access control, and the site's terms still apply (RFC 9309). How to read both before you scrape: how to check if scraping is allowed. What a 429 means and how to fix it: 429 Too Many Requests.
Step 9. Pages that load data with JavaScript
If the data is not in the page source, open the browser's developer tools, go to the Network tab, filter by Fetch/XHR and reload. Most modern sites load their data as JSON from an API endpoint, which Requests can call directly and which is easier to parse than HTML:
data = session.get("https://example.com/api/products?page=1", timeout=30).json()If there is no such request, or it needs tokens the page computes, render the page in a browser with Playwright or Selenium. That is the subject of Python dynamic web scraping and web scraping with Selenium.
The complete scraper
The steps above in one file, with the robots.txt check applied before every page:
# pip install requests beautifulsoup4 -> python books.py
import csv, time
from urllib.robotparser import RobotFileParser
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter, Retry
START = "https://books.toscrape.com/" # 1. your start URL
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; books-tutorial/1.0; [email protected])"}
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
PROXY = None # e.g. "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
session = requests.Session()
session.headers.update(HEADERS)
session.mount("https://", HTTPAdapter(max_retries=Retry(total=4, backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504], respect_retry_after_header=True)))
if PROXY:
session.proxies = {"http": PROXY, "https": PROXY}
robots = RobotFileParser(requests.compat.urljoin(START, "/robots.txt"))
robots.read()
def parse_book(card, base): # 3. your field selectors
link = card.select_one("h3 a")
return {"title": link["title"],
"price": card.select_one("p.price_color").get_text(strip=True).lstrip("£"),
"rating": RATINGS.get(card.select_one("p.star-rating")["class"][1]),
"in_stock": "In stock" in card.select_one("p.availability").get_text(),
"url": requests.compat.urljoin(base, link["href"])}
def scrape(start):
url, rows = start, []
while url and robots.can_fetch(HEADERS["User-Agent"], url):
r = session.get(url, timeout=30)
r.raise_for_status()
r.encoding = "utf-8" # the site sends no charset header
soup = BeautifulSoup(r.text, "html.parser")
rows += [parse_book(c, r.url) for c in soup.select("article.product_pod")] # 2. card selector
nxt = soup.select_one("li.next a") # 4. next-link selector
url = requests.compat.urljoin(r.url, nxt["href"]) if nxt else None
time.sleep(1)
return rows
rows = scrape(START)
with open("books.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(rows)
print(f"saved {len(rows)} books")Run it with python books.py. Expected output, after about 80 seconds (50 pages at one per second):
saved 1000 booksand the first rows of books.csv:
title,price,rating,in_stock,url
A Light in the Attic,51.77,3,True,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,1,True,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
Soumission,50.10,1,True,https://books.toscrape.com/catalogue/soumission_998/index.htmlAdapt it to your site
The code is marked where a different site needs changes. Open the site in the browser's Inspect panel (step 2) and replace:
START: the first page of the listing you want.- The card selector
article.product_pod: the element that wraps one item. - The field selectors in
parse_book(h3 a,p.price_color,p.star-rating,p.availability): one per field you need, and the dictionary keys, which become the CSV columns. - The next-link selector
li.next a: the "Next" link or button; numbered pages, offsets and infinite scroll are covered in web scraping pagination.
If rows stays empty, the selectors do not match or the data loads with JavaScript (step 9).
Scaling up: when a script is not enough
- Thousands of pages from one site: move to Scrapy, which adds concurrency, retries, deduplication and exports (what Scrapy is), or fetch concurrently with httpx and asyncio.
- Running every day: schedule the script with cron or a job runner (schedule web scrapers).
- Many sites or a whole site: crawl with link discovery and limits (Python web crawler).
- Other libraries: the full comparison of clients, parsers, frameworks and browsers is in Python libraries for web scraping.
Add a proxy when the site limits your IP
Sites cap how many requests one IP may send. When your scraper starts getting 429 or 403 responses at a polite pace, or when you need pages as they appear in another country, route the requests through rotating proxies. In the complete scraper, set PROXY to the ProxyField gateway:
PROXY = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"Each request then leaves from a new residential IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. SOCKS5, sticky sessions and rotation patterns are in the Python proxy setup guide. ProxyField residential proxies cost $2.95/GB for 1 GB and fall to $1.95/GB from 1,000 GB, with no subscription, unlimited concurrent threads and traffic that never expires; all purchases are final. Each catalog page in this tutorial is about 51 KB of HTML (Requests downloads no images), so the 50-page run moves about 2.5 MB. Scrape public data and follow each site's rules (is web scraping legal), then start with residential proxies and the Easy Trial: 1 GB for $2.95.
Web Scraping With Python FAQ
How do I web scrape with Python?
ProxyField's tutorial does it in 9 steps and about 50 lines of Python: install Requests and Beautiful Soup, inspect the page, fetch it, parse the HTML, select the fields with CSS selectors, follow the next-page links, save to CSV, add retries and pauses, and switch to the page's JSON requests or a browser if the data loads with JavaScript. When one IP is not enough, ProxyField residential proxies plug into Requests with one argument from $1.95/GB.
How do I write a Python program that scrapes a website, follows the next-page links and saves everything to a CSV?
Loop while there is a next page: fetch it with requests, parse it with Beautiful Soup, append one dictionary per item, find the next link with soup.select_one("li.next a") and turn it into an absolute URL with urljoin; stop when it returns None. Then write the list with csv.DictWriter. The complete scraper on this page does exactly that and saves 1,000 rows from 50 pages; change its 4 selectors for your site.
Do I need to know HTML and CSS before I start web scraping with Python, or can I pick it up as I go?
You can pick it up as you go. You need to read tags, the class, id and href attributes and how elements nest, and CSS only for selecting elements, not styling: this tutorial uses 4 selectors (article.product_pod, h3 a, p.price_color, li.next a). Step 2, the browser's Inspect panel, is how you learn them on any page.
What is web scraping in Python?
Writing a Python program that downloads web pages and extracts the data you need from them into rows, such as names, prices or links. Python is popular for it because libraries such as Requests, Beautiful Soup, Scrapy and Playwright cover every part of the job.
How do I set up a Python environment for web scraping?
Install Python 3.10 or newer, create a virtual environment with python3 -m venv .venv, activate it and run pip install requests beautifulsoup4. Add playwright later if you need a browser, and keep the packages in a requirements.txt file.
When should I use Python Requests for scraping?
Whenever the data is in the HTML the server sends or in a JSON request the page makes, which covers most sites. Requests is faster and uses far less traffic than a browser; use Playwright or Selenium only when the data appears after JavaScript runs.
How do I scrape multiple pages with Python?
Find the next-page link on each page and loop until it disappears, or loop over page numbers in the URL, with a pause between requests. For thousands of pages, fetch concurrently with httpx and asyncio or move to Scrapy.
How do I scrape product information with Python?
Collect the product URLs from the listing pages, then fetch each product page and extract the name, price, rating and availability with CSS selectors. Check first whether the site loads product data from a JSON request, which is often cleaner than the HTML.
Can I scrape any website using Python?
Technically, Python can fetch any public page, but not every site may be scraped: check its terms and robots.txt, collect only public data, and keep a pace the site can handle. Pages behind logins you are not entitled to, and personal data, need particular care.
Why does a page work in my browser but not in my Python script?
Usually because the data loads with JavaScript after the first response, or because the site treats your script's headers, cookies or IP differently from a browser. Check the page source and the Network tab, send a realistic User-Agent, and slow down if you get 403 or 429 errors.
Show 2 more questionsShow fewer questions
How do I extract a table from a web page with Python?
For a plain HTML <table>, pandas.read_html(io.StringIO(html)) returns every table on the page as a DataFrame in one call. For tables built from <div> elements, select the rows and cells with Beautiful Soup and build the rows yourself.
How is web scraping with Python used in data science?
To build datasets that do not exist as downloads: prices over time, job listings, reviews or public records. The scraper writes rows straight into pandas or a database, where the analysis or model training starts.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.