EDF usage to CSV

EDF's account pages only let you download one day of half-hourly usage at a time. This small script downloads any date range in one go, a month or a whole year, and saves it as a CSV you can open in Excel.

Your login stays with you

Download

Download edf_usage_csv.py

Needs Python 3.8 or later. Nothing else to install.

How to run it

Mac

  1. Put edf_usage_csv.py in a folder, for example your Downloads folder.
  2. Open Terminal (Applications → Utilities).
  3. Go to the folder and run the script:
    cd ~/Downloads
    python3 edf_usage_csv.py
    If your Mac asks to install "command line developer tools", say yes, then run it again.

Windows

  1. If you don't have Python, install it from python.org. Tick "Add python.exe to PATH" during install.
  2. Put edf_usage_csv.py in a folder, for example Downloads.
  3. Open Command Prompt and run:
    cd %USERPROFILE%\Downloads
    py edf_usage_csv.py

Then

  1. Enter your EDF email and password (the password won't show as you type).
  2. Enter the from and to dates as YYYY-MM-DD, for example 2026-09-01 and 2026-09-30.
  3. The CSV is saved in the same folder, named like edf_usage_1234_5678_20260901_20260930.csv.

What you get

The code

This is the exact file you download.

#!/usr/bin/env python3
"""
FPRates - EDF half-hourly usage to CSV

Runs on your own computer. Your EDF login is used only to talk to EDF's own
API (api.edfgb-kraken.energy). Nothing is sent to FPRates or anywhere else.
Needs Python 3.8 or later and nothing else.
"""
import csv, getpass, json, os, sys, urllib.error, urllib.parse, urllib.request
from datetime import datetime, timedelta

BASE = "https://api.edfgb-kraken.energy/v1"
GQL = BASE + "/graphql/"


def post_json(url, payload, token=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = token
    req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers)
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)


def get_json(url, auth):
    req = urllib.request.Request(url, headers={"Authorization": auth})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)


def login(email, password):
    q = ("mutation($e: String!, $p: String!) "
         "{ obtainKrakenToken(input: {email: $e, password: $p}) { token } }")
    res = post_json(GQL, {"query": q, "variables": {"e": email, "p": password}})
    try:
        return res["data"]["obtainKrakenToken"]["token"]
    except (KeyError, TypeError):
        sys.exit("Login failed - check your EDF email and password.")


def find_meters(token):
    q = """query { viewer { accounts { number ... on AccountType { properties {
             electricityMeterPoints { mpan meters { serialNumber } } } } } } }"""
    res = post_json(GQL, {"query": q}, token)
    if res.get("errors"):
        sys.exit(f"Couldn't read your meters: {res['errors']}")
    meters = []
    for acc in res["data"]["viewer"]["accounts"]:
        for prop in acc.get("properties") or []:
            for mp in prop.get("electricityMeterPoints") or []:
                for m in mp.get("meters") or []:
                    if m.get("serialNumber"):
                        meters.append((mp["mpan"], m["serialNumber"]))
    return meters


def fetch(token, mpan, serial, start, end):
    params = urllib.parse.urlencode({
        "period_from": start.isoformat(), "period_to": end.isoformat(),
        "page_size": 25000, "order_by": "period"})
    url = f"{BASE}/electricity-meter-points/{mpan}/meters/{serial}/consumption/?{params}"
    for auth in (token, f"Bearer {token}", f"JWT {token}"):
        try:
            rows, next_url = [], url
            while next_url:
                data = get_json(next_url, auth)
                rows.extend(data["results"])
                next_url = data.get("next")
            return rows
        except urllib.error.HTTPError as e:
            if e.code in (401, 403):
                continue          # try the next way of sending the login token
            if e.code == 404:
                return []         # old or replaced meter
            raise
    sys.exit("EDF refused the usage request (not authorised).")


def ask_date(prompt):
    while True:
        s = input(prompt).strip()
        try:
            return datetime.strptime(s, "%Y-%m-%d")
        except ValueError:
            print("  Please use YYYY-MM-DD, for example 2026-09-01")


def main():
    print(__doc__)
    email = os.environ.get("EDF_EMAIL") or input("EDF email: ").strip()
    password = os.environ.get("EDF_PASS") or getpass.getpass("EDF password (not shown): ")
    d_from = ask_date("From date (YYYY-MM-DD): ")
    d_to = ask_date("To date, inclusive (YYYY-MM-DD): ")
    if d_to < d_from:
        sys.exit("The 'to' date is before the 'from' date.")
    start = d_from.astimezone()                       # local midnight
    end = (d_to + timedelta(days=1)).astimezone()     # midnight after the 'to' date

    token = login(email, password)
    meters = find_meters(token)
    if not meters:
        sys.exit("No electricity meters found on this account.")

    for mpan, serial in meters:
        rows = fetch(token, mpan, serial, start, end)
        by_start = {}
        for r in rows:
            s = datetime.fromisoformat(r["interval_start"].replace("Z", "+00:00"))
            if start <= s < end:
                by_start[s] = r
        rows = [by_start[k] for k in sorted(by_start)]
        label = f"MPAN ...{mpan[-4:]}, meter ...{serial[-4:]}"
        if not rows:
            print(f"{label}: no readings in that period - skipped")
            continue
        fname = f"edf_usage_{mpan[-4:]}_{serial[-4:]}_{d_from:%Y%m%d}_{d_to:%Y%m%d}.csv"
        total = 0.0
        with open(fname, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["start", "end", "kWh"])
            for r in rows:
                s = datetime.fromisoformat(r["interval_start"].replace("Z", "+00:00")).astimezone()
                e = datetime.fromisoformat(r["interval_end"].replace("Z", "+00:00")).astimezone()
                w.writerow([s.strftime("%Y-%m-%d %H:%M"), e.strftime("%Y-%m-%d %H:%M"), r["consumption"]])
                total += r["consumption"]
        expected = int((end - start).total_seconds() // 1800)
        print(f"{label}: saved {len(rows)} half-hours ({total:.2f} kWh) to {fname}")
        if len(rows) < expected:
            print(f"  Note: {expected - len(rows)} half-hours missing - EDF may not have received them yet.")


if __name__ == "__main__":
    main()

FPRates is an independent service and is not affiliated with, endorsed by, or connected to EDF Energy in any way. EDF and FreePhase are trademarks of EDF Energy.

100% score in internet.nl website test 100% score in internet.nl email test