DEV Community

Cover image for How to Scrape Tesla Supercharger Locations and Prices with Python
ReventaData
ReventaData

Posted on Edited on Originally published at apify.com

How to Scrape Tesla Supercharger Locations and Prices with Python

By the end of this tutorial, you will have a short Python script that turns a map box into one row per Tesla Supercharger site: name, address, coordinates, stall count, max power in kW, amenities, access hours, and the site's per-kWh time-of-use rates and congestion fees.

Tesla publishes this on its Find Us map, but tesla.com sits behind Akamai Bot Manager: in testing on 2026-09-27, direct, datacenter and residential requests got a short refusal page, while unblocking proxies, Apify Unblocker among them, reached some of the tested pages.

In this guide, we use Python and the Apify Actor Tesla Supercharger Scraper (reventadata/tesla-scraper). It is unofficial and not affiliated with Tesla. The rows shown below are a sample from a capture dated 2026-09-22, parsed with the Actor's own code.

⏱️ Time estimate: one sitting: install one package, set one token, run four short Python blocks.


Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. An Apify account and its API token, which sits on the API & Integrations page in Apify Console.

Install the client:

pip install "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will turn a bounding box (west, south, east, north in decimal degrees) into one row per Supercharger site inside it. Each row carries the site id (Tesla's location slug) and url, name, street address, city, country, latitude and longitude, stallCount, maxPowerKw, openToNonTeslas, amenities and accessHours.

On top of that come the prices: priceKwhFrom (the lowest per-kWh charging rate), congestionFeePerMinMax (the highest per-minute congestion fee), the full pricebooks list and 24-value congestionByWeekday curves.

Example use case: Suppose you want every site in a region that a non-Tesla EV can use, with at least 8 stalls. You would set the box, openToNonTeslaOnly: true and minStalls: 8, and every saved row would match both.


Step 1: Access Tesla Supercharger Scraper

Go to the Tesla Supercharger Scraper page on Apify Store. The README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.


Step 2: Configure the box

The four required inputs are the box edges: bboxWest and bboxEast are longitudes (-180 to 180), bboxSouth and bboxNorth are latitudes (-90 to 90), all WGS84 decimal degrees. West must be less than east and south less than north.

The Actor first reads Tesla's world-wide list of Supercharger pins. Pins carry coordinates and a slug but no names, so the box is applied to them locally, before any per-site request. Every pin inside the box then costs one detail request, which is why maxItems (default 100) matters: it caps the sites saved.

Here is the setup. It uses the Actor's prefill box, the area around Madrid, and saves at most 10 sites:

import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Define the box (the Actor's prefill: around Madrid) and a site cap
run_input = {
    "bboxWest": -3.9,
    "bboxSouth": 40.3,
    "bboxEast": -3.5,
    "bboxNorth": 40.6,
    "maxItems": 10,
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: a quick way to get a box is to note the corner coordinates of the area on any web map. For a wider area, widen the box; the Actor's tests use -9.3, 35.9, 3.4, 43.8 as their Spain box.

No proxy setting is needed: the proxy field is display-only, and the Actor always routes through an unblocking proxy with automatic fallback.


Step 3: Add optional filters

These filters run on each site's row after its detail request, so a site they drop never reaches your dataset:

Parameter Type What it keeps
searchText string Sites whose name contains the text, ignoring case. Empty = off.
minStalls integer Sites with at least N public stalls. 0 = off.
minPowerKw integer Sites whose max power is at least N kW. 0 = off.
openToNonTeslaOnly boolean Sites where non-Tesla vehicles can charge.

Three combinations that work:

  • Fast sites only: the box plus minPowerKw: 250.
  • Big sites for non-Teslas: openToNonTeslaOnly: true, minStalls: 8.
  • One named site: searchText: "Majadahonda" with a box around it.

⚠️ A box with west not less than east, or south not less than north, stops the run with an error at its start. A missing edge, or one outside the degree range, is refused before the run starts.


Step 4: Run the Actor

Now start the run. .call() starts the Actor on Apify and waits for it to finish before returning the run:

# 3. Start the Actor run and wait for it to finish
print("Starting Supercharger extraction...")
run = client.actor("reventadata/tesla-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the saved sites
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} Supercharger sites.\n")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor fetches the pin list once and keeps the pins inside your box.
  • It fetches site details three at a time, applies your filters, and saves each row as it lands, so rows arrive in completion order, not map order.
  • It stops at maxItems or when the box has no pins left.

Step 5: View and download results

Loop over the rows to print the parts you need:

for site in items:
    print(site["name"])
    print(f"  Stalls: {site.get('stallCount')}, max {site.get('maxPowerKw')} kW")
    print(f"  Open to non-Teslas: {site.get('openToNonTeslas')}")
    if site.get("priceKwhFrom") is not None:
        print(f"  Charging from {site['priceKwhFrom']} {site.get('priceCurrency')}/kWh")
    print("-" * 40)
Enter fullscreen mode Exit fullscreen mode

To see a site's time-of-use rates, walk its pricebooks:

# 5. Charging rates per vehicle group and time window for the first site
site = items[0]
for book in site["pricebooks"]:
    if book["feeType"] == "CHARGING":
        window = f"{book['startTime']}-{book['endTime']}" if book["isTou"] else "no window"
        print(book["vehicleMakeType"], window, book["rateBase"], book["currencyCode"])
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-09-22, parsed with the Actor's own code. pricebooks and congestionByWeekday are left out here for length; a saved row also carries scrapedAt, the run's timestamp:

{
    "id": "299962",
    "name": "Majadahonda, Spain",
    "url": "https://www.tesla.com/findus?location=299962",
    "openToPublic": true,
    "openToNonTeslas": true,
    "accessType": "Public",
    "address": "C.C. Gran Plaza 2, C. de los Químicos",
    "city": "Majadahonda",
    "state": "Comunidad de Madrid",
    "postalCode": "28222",
    "country": "Spain",
    "countryCode": "ES",
    "latitude": 40.489725,
    "longitude": -3.898257,
    "timeZone": "Europe/Madrid",
    "stallCount": 20,
    "maxPowerKw": 250.0,
    "amenities": [
        "AMENITIES_WIFI",
        "AMENITIES_RESTROOMS",
        "AMENITIES_CAFE",
        "AMENITIES_SHOPPING",
        "AMENITIES_RESTAURANT"
    ],
    "accessHours": {
        "regularHours": [
            {"weekday": 1, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 2, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 3, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 4, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 5, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 6, "periodBegin": "08:00", "periodEnd": "23:59"},
            {"weekday": 7, "periodBegin": "08:00", "periodEnd": "23:59"}
        ],
        "twentyFourSeven": false
    },
    "isTrailerFriendly": false,
    "priceCurrency": "EUR",
    "priceKwhFrom": 0.2,
    "congestionFeePerMinMax": 0.5
}
Enter fullscreen mode Exit fullscreen mode

And one of that site's 18 pricebooks entries, the one that set priceKwhFrom:

{
    "feeType": "CHARGING",
    "rateBase": 0.2,
    "rateTier1": 0,
    "rateTier2": 0,
    "rateMinTier1": null,
    "rateMinTier2": null,
    "rateMinTier3": null,
    "rateMinTier4": null,
    "currencyCode": "EUR",
    "uom": "kwh",
    "startTime": "04:00",
    "endTime": "09:00",
    "days": "0,1,2,3,4,5,6",
    "isTou": true,
    "vehicleMakeType": "TSLA",
    "minSiteOccupancy": null,
    "maxSiteOccupancy": null,
    "isMemberPricebook": true
}
Enter fullscreen mode Exit fullscreen mode

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.


Understanding your results

The fields that matter most:

  • stallCount and maxPowerKw: public stalls and the site's top power. The captured site had 20 stalls at 250 kW.
  • openToNonTeslas: true when other EVs can charge there; it was true at the captured site.
  • pricebooks: Tesla's rate entries, verbatim. Each has a feeType (CHARGING per kWh or CONGESTION per minute), a rateBase, a time window when isTou is true, and a vehicleMakeType. The captured site had 18: 15 charging and 3 congestion, split evenly across TSLA, NTSLA and MSP. Only the TSLA entries are flagged isMemberPricebook: true.
  • priceKwhFrom and congestionFeePerMinMax: the lowest charging rate and highest congestion fee across those entries (0.2 and 0.5 EUR there). They are summaries, not what every driver pays.
  • congestionByWeekday: seven lists of 24 values, Tesla's own congestion curve, copied as is.
  • accessHours: opening periods per weekday (08:00 to 23:59 every day at the captured site).

Prices are from 2026-09-22 and may have changed since. Stall availability is not live: rows carry counts, not free stalls.


Pro tips

  1. Size the box to the cap. Each site the Actor opens costs one detail request, whether or not a filter then drops it, and it keeps opening sites until maxItems rows are saved or the box runs out. Keep maxItems low on a big box until you know what it holds.
  2. Filter before you analyse. minPowerKw, minStalls and openToNonTeslaOnly drop rows before they are saved, so your dataset holds only the sites you want.
  3. Compare like with like. priceKwhFrom is the lowest entry across all vehicle groups and windows; for a fair comparison, read the pricebooks entry for the same vehicleMakeType and window at each site.
  4. Split large regions. Rows arrive in completion order, so sort by id or name yourself, and run one box per region to keep each dataset small.
  5. Need whole countries or planned sites? The README points to the sibling Supercharger Database Scraper, which reads supercharge.info data without prices.

Troubleshooting

  • Run FAILED with zero rows: a run that is blocked or cannot read a page ends FAILED; an empty box or an over-strict filter that still loads normally ends SUCCEEDED with an empty dataset and a status message saying so. Check the edges are in the right order and in degrees.
  • Run stops at once: west is not less than east, or south not less than north. A box cannot cross the 180th meridian; split it in two.
  • Fewer rows than expected: read the summary line, Run completed. Pushed N rows. Rejected: {...}; it counts drops per reason, such as search-mismatch, min-stalls, min-power, tesla-only and no-site. From Python: client.log(run.id).get().

For help, use the Actor's Store page.


Pricing and next steps

For current pricing and details, see the Store page.

To track rate changes, save the input as a task and add an Apify schedule, which runs it on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.

Which region's chargers are you planning to map? Drop a comment! 💬

Top comments (0)