PYTH-240 · Lesson 7 of 12 · 25 min

Automating spreadsheets

▶ Video coming soon A video version of this lesson is on the way.

Spreadsheets run small businesses. At Ridgeline, Anita keeps the books in a workbook with one sheet per month, Theo plans stock in another, and Kai's roast plan lives in a third. Every Monday, numbers move between these workbooks and the services from earlier lessons by copy and paste: online sales from the shop into Anita's sheet, supplier prices into Kai's plan, delivery dates from the carrier into Theo's tracker. A script can read and write spreadsheets directly, which removes the copying, the typos that come with it and the hour it takes.

This lesson shows how spreadsheet ideas map onto Python: workbooks, sheets, cells, ranges and formulas. It works with CSV, which every spreadsheet program opens, and with real Excel workbooks through the openpyxl library. It covers building summaries such as pivot tables in plain Python, writing formulas and formatting, handling spreadsheet dates, reconciling two sources, and a security issue every exporter must know about: formula injection.

openpyxl is a third-party package and is not available to the browser Python on this page, so listings that use it are shown without a Run button. The calculations around them, which are where the logic lives, run here in plain Python.

Workbooks, sheets, cells and ranges

A spreadsheet file is a workbook containing one or more worksheets. Each worksheet is a grid of cells, addressed by a column letter and a row number in what is called A1 notation: A1 is the top-left cell, C7 is the third column of the seventh row, and after Z come AA, AB and so on, up to XFD, the 16,384th column in current Excel files. A rectangular block of cells is a range, written with a colon: B2:B8 is seven cells of column B. A cell holds a value, such as a number, text or date, or a formula, which starts with an equals sign and is calculated by the spreadsheet program, such as =SUM(B2:B8).

Programs usually prefer row and column numbers to letters, so converting between the two is a common small task. Column letters work like a number written in base 26, except that there is no zero digit: A is 1, Z is 26, AA is 27. The listing converts in both directions.

Converting between column letters and numbers
def col_number(letters):
    n = 0
    for ch in letters.upper():
        n = n * 26 + (ord(ch) - ord("A") + 1)
    return n

def col_letters(n):
    out = ""
    while n:
        n, r = divmod(n - 1, 26)
        out = chr(ord("A") + r) + out
    return out

for letters in ("A", "Z", "AA", "AZ", "BA", "XFD"):
    print(letters, col_number(letters), col_letters(col_number(letters)))
Output
A 1 A
Z 26 Z
AA 27 AA
AZ 52 AZ
BA 53 BA
XFD 16384 XFD

Try it yourself

Cell names

Write cell_name(row, col) that returns the A1-style name of a cell given its row and column numbers, both starting at 1. For example, cell_name(1, 1) is "A1", cell_name(7, 3) is "C7" and cell_name(2, 28) is "AB2".

def cell_name(row, col):
    return chr(ord('A') + col - 1) + str(row)

print(cell_name(2, 28))

CSV or Excel?

For moving data between programs, CSV is often the best choice: every spreadsheet program, database and language reads it, it is plain text that version control can compare, and lesson 4 showed how to read and write it safely. Its limits are just as clear. A CSV file holds one table of plain values: no formulas that stay live, no formatting, no multiple sheets, no charts. When a person will open the result and work in it, or when the source is a workbook with several sheets and formulas, use the real format.

For Excel's .xlsx files, the standard Python library is openpyxl, installed with python -m pip install openpyxl. It reads and writes workbooks without needing Excel installed, so it runs on servers. The pandas library, popular for data analysis, reads and writes spreadsheets too and is handy when the work is mostly calculation on tables. Google Sheets has its own API, which uses the OAuth tokens of lesson 3 and the REST patterns of lesson 2.

Reading a workbook with openpyxl

Anita's workbook has a sheet named Nov 2026 with a header row and one row per day: date, till sales, online sales, refunds and a formula column for the net total. Reading it takes a few lines. load_workbook opens the file, the workbook is indexed by sheet name, and iter_rows walks the rows, returning values when values_only=True. One detail trips up everyone once: by default, a formula cell gives back the formula text, such as =B2+C2-D2, not its result. Opening with data_only=True returns the result instead, as calculated the last time the file was saved by a spreadsheet program; openpyxl itself never calculates formulas.

Reading one month of Anita's books (needs openpyxl and the file)
from openpyxl import load_workbook

wb = load_workbook("ridgeline-books-2026.xlsx", data_only=True)   # results, not formula text
ws = wb["Nov 2026"]
print(ws.dimensions)                                               # e.g. A1:E31

days = []
for date, till, online, refunds, net in ws.iter_rows(min_row=2, values_only=True):
    if date is None:
        break                                                      # stop at the first empty row
    days.append({"date": date, "till": till, "online": online, "refunds": refunds, "net": net})
print(len(days), "days read; first:", days[0])

Summaries and pivot tables in Python

The most common spreadsheet job is summarizing: totals per day, per product, per channel. In a spreadsheet this is a pivot table; in Python it is a dictionary of totals, usually built with collections.defaultdict or Counter. The listing turns a week of online order lines from the shop into a cross-tabulation with one row per coffee and one column per day, plus totals, the table Theo used to build by hand.

A pivot table of bags sold per coffee per day
from collections import defaultdict
from decimal import Decimal

lines = [("Mon", "RC-101", 4), ("Mon", "RC-104", 1), ("Tue", "RC-101", 2), ("Tue", "RC-102", 3),
         ("Wed", "RC-104", 2), ("Thu", "RC-101", 5), ("Thu", "RC-102", 1), ("Fri", "RC-106", 2),
         ("Sat", "RC-101", 7), ("Sat", "RC-104", 1), ("Sun", "RC-106", 1)]
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

table = defaultdict(lambda: defaultdict(int))
for day, sku, bags in lines:
    table[sku][day] += bags

print("SKU    " + "".join(f"{d:>5}" for d in days) + "  Total")
for sku in sorted(table):
    row = [table[sku][d] for d in days]
    print(f"{sku:7}" + "".join(f"{n:>5}" for n in row) + f"{sum(row):>7}")
col_totals = [sum(table[s][d] for s in table) for d in days]
print("Total  " + "".join(f"{n:>5}" for n in col_totals) + f"{sum(col_totals):>7}")
Output
SKU      Mon  Tue  Wed  Thu  Fri  Sat  Sun  Total
RC-101     4    2    0    5    0    7    0     18
RC-102     0    3    0    1    0    0    0      4
RC-104     1    0    2    0    0    1    0      4
RC-106     0    0    0    0    2    0    1      3
Total      5    5    2    6    2    8    1     29
On the left, a long table with columns day, SKU and bags: Mon RC-101 4, Mon RC-104 1, Tue RC-101 2 and so on. An arrow labeled group by SKU and day, add bags points to a wide table on the right with SKUs down the side, days across the top, a Total column and a Total row. The cell for RC-101 on Saturday shows 7 and is highlighted, with a thin line back to the matching row in the long table.
A pivot turns a long list of records into a grid of totals

Try it yourself

Totals by channel

Anita's sales arrive as a list of tuples (day, channel, amount), where amount is a string such as "18.50". Write channel_totals(sales) that returns a dictionary mapping each channel to its total as a Decimal. The channels are not known in advance.

def channel_totals(sales):
    totals = {}
    for day, channel, amount in sales:
        totals[channel] = float(amount)
    return totals

print(channel_totals(SALES))

Once the totals exist as Python data, writing them out is easy, and CSV is the quickest route into anyone's spreadsheet program. The listing writes the pivot from above as a CSV file that opens with one column per day. Writing the header from the same days list used to build the table guarantees the columns line up, and adding the totals as a final row keeps the file readable for a person while staying simple for any program that reads it later.

Writing a pivot table as CSV with a totals row
import csv, io

days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
table = {"RC-101": {"Mon": 4, "Tue": 2, "Thu": 5, "Sat": 7}, "RC-104": {"Mon": 1, "Wed": 2, "Sat": 1}}

out = io.StringIO()                       # a real script: open("week-45.csv", "w", newline="")
writer = csv.writer(out, lineterminator="\n")
writer.writerow(["SKU"] + days + ["Total"])
for sku in sorted(table):
    row = [table[sku].get(d, 0) for d in days]
    writer.writerow([sku] + row + [sum(row)])
totals = [sum(table[s].get(d, 0) for s in table) for d in days]
writer.writerow(["Total"] + totals + [sum(totals)])
print(out.getvalue())
Output
SKU,Mon,Tue,Wed,Thu,Fri,Sat,Sun,Total
RC-101,4,2,0,5,0,7,0,18
RC-104,1,0,2,0,0,1,0,4
Total,5,2,2,5,0,8,0,22

Automation that touches the books needs to be trustworthy, and three habits make it so. First, never overwrite the only copy: write to a new file, or keep the previous version, so a bug cannot destroy a month of records. Second, check totals both ways: if the script copies 31 days of sales, it should also confirm that its grand total equals the sum of the source rows, and stop if it does not. Third, leave a trail: a short line in a log, or a note cell in the sheet, saying which script wrote the data and when, so that anyone who finds a surprising number knows where it came from.

Writing a workbook people will use

A report that people will open in Excel should look like one. openpyxl creates a Workbook, adds sheets, writes values with ws.append(row) or by assigning to ws["B2"], and saves with wb.save(path). It can also write formulas, as strings starting with an equals sign, so that the totals in the sheet stay live when someone edits a number; apply number formats so money shows two decimals and dates look like dates; bold the header row; set column widths; and freeze the header so it stays visible while scrolling. The listing writes Theo's weekly table with all of these touches.

Writing a formatted weekly workbook with live formulas (needs openpyxl)
from openpyxl import Workbook
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter

rows = [("RC-101", 18.50, 18), ("RC-102", 22.00, 4), ("RC-104", 20.25, 4), ("RC-106", 21.50, 3)]
wb = Workbook()
ws = wb.active
ws.title = "Week 45"
ws.append(["SKU", "Price", "Bags sold", "Revenue"])
for r, (sku, price, bags) in enumerate(rows, start=2):
    ws.append([sku, price, bags, f"=B{r}*C{r}"])            # live formula per row
last = len(rows) + 1
ws.append(["Total", None, f"=SUM(C2:C{last})", f"=SUM(D2:D{last})"])
for cell in ws[1]:
    cell.font = Font(bold=True)
for row in ws.iter_rows(min_row=2, min_col=2, max_col=4):
    row[0].number_format = row[2].number_format = "#,##0.00"
for col in range(1, 5):
    ws.column_dimensions[get_column_letter(col)].width = 12
ws.freeze_panes = "A2"                                        # keep the header visible
wb.save("week-45.xlsx")

Writing formulas rather than computed numbers is a choice. Formulas keep the sheet consistent when a person edits it, which suits planning sheets. Computed numbers are safer for reports that should be a fixed record of what the script found, and they can be checked in Python before writing. Theo's table uses formulas because he adjusts the quantities by hand; Anita's month-end export writes values, because the books must not change after they are closed.

Dates in spreadsheets

Spreadsheet programs store dates as numbers: the count of days since a starting point, with the time of day as a fraction. In Excel's usual system, day 1 is 1 January 1900, and because of an old compatibility quirk, the practical way to convert is to count days from 30 December 1899. openpyxl converts cells formatted as dates into Python datetime objects for you, but dates arriving as bare numbers, from a CSV export or an older system, need converting by hand, as does any number you suspect is really a date.

Converting spreadsheet serial dates
from datetime import date, datetime, timedelta

EPOCH = datetime(1899, 12, 30)

def from_serial(serial):
    return EPOCH + timedelta(days=serial)

def to_serial(d):
    return (datetime(d.year, d.month, d.day) - EPOCH).days

print(from_serial(46000).date())
print(from_serial(46000.75))              # 18:00 on the same day
print(to_serial(date(2026, 11, 9)))
Output
2025-12-09
2025-12-09 18:00:00
46335

Formula injection

Exporting data to CSV for a spreadsheet has a security trap. If a cell value begins with =, +, - or @, spreadsheet programs may treat it as a formula when the file is opened. A customer who types =HYPERLINK("http://example.invalid","Click") as their company name on an order form could make that link appear in Anita's export, and some formulas can do worse when a user accepts the program's prompts. This is called formula injection or CSV injection. Any export that includes text typed by outside people, such as names, notes or addresses, should neutralize such values, commonly by putting a single quote in front, which spreadsheet programs treat as "this is text".

Neutralizing values that a spreadsheet could read as formulas
RISKY = ("=", "+", "-", "@", "\t", "\r")

def safe_cell(value):
    text = str(value)
    if text.startswith(RISKY):
        return "'" + text
    return text

for v in ["Lee, Ana", "=HYPERLINK(\"http://example.invalid\",\"Click\")", "+1 403 555 0100", "@SUM(A1)", 42]:
    print(repr(safe_cell(v)))
Output
'Lee, Ana'
'\'=HYPERLINK("http://example.invalid","Click")'
"'+1 403 555 0100"
"'@SUM(A1)"
'42'

Neutralizing changes how a number such as -3 or a phone number written with a plus sign looks, so apply it to free-text fields typed by outsiders, not to columns of numbers your own program calculated. When writing a real .xlsx file with openpyxl, a value stored as text in a cell is not run as a formula unless it is a string starting with an equals sign, so the same rule applies: never write text from outside sources into a cell as-is if it might begin with =.

Try it yourself

Make an export safe

Write safe_row(row) that takes a list of values from a customer record and returns a new list of strings in which every value that starts with =, +, - or @ has a single quote added in front. Other values are converted to strings unchanged.

def safe_row(row):
    return [str(v) for v in row]

print(safe_row(['Okafor', '=1+2', 7]))

Reconciling two sources

A valuable automation is reconciliation: checking that two sources agree. Anita compares the shop's daily online totals with the payment processor's deposits; Theo compares the bags the stock sheet says were sold with the bags the shop recorded. A script lines the two up by date or by SKU, computes the differences, and reports only the rows that disagree, which turns an hour of eyeballing into a short list of things to investigate.

Reconciling shop totals against deposits by day
from decimal import Decimal

shop = {"11-02": "212.50", "11-03": "98.75", "11-04": "143.00", "11-05": "77.25"}
deposits = {"11-02": "212.50", "11-03": "95.50", "11-05": "77.25", "11-06": "40.00"}

for day in sorted(shop.keys() | deposits.keys()):
    a = Decimal(shop.get(day, "0"))
    b = Decimal(deposits.get(day, "0"))
    if a != b:
        note = "missing deposit" if day not in deposits else "no shop sales" if day not in shop else "amounts differ"
        print(f"{day}  shop {a:>7}  deposits {b:>7}  difference {a - b:>7}  {note}")
Output
11-03  shop   98.75  deposits   95.50  difference    3.25  amounts differ
11-04  shop  143.00  deposits       0  difference  143.00  missing deposit
11-06  shop       0  deposits   40.00  difference  -40.00  no shop sales

The union of the two key sets, shop.keys() | deposits.keys(), makes sure days present in only one source are reported too, which is where the most interesting problems hide. On 3 November the difference of 3.25 turned out to be a refund the shop recorded the next day; on 4 November a whole deposit was missing because of a bank holiday; the 6 November deposit without shop sales was a correction.

Common mistakes

  • MistakeReading a workbook and getting formula text such as =B2+C2 instead of numbers.

    InsteadOpen it with data_only=True to read the results last saved by a spreadsheet program.

  • MistakeAssuming openpyxl calculates formulas.

    InsteadIt only stores them; the spreadsheet program calculates them when the file is opened.

  • MistakeExporting customer-typed text to CSV without neutralizing it.

    InsteadPrefix values that start with =, +, - or @ with a single quote.

  • MistakeTreating a spreadsheet date that arrives as 46000 as a plain number.

    InsteadConvert serial dates from 30 December 1899.

  • MistakeUsing CSV for a report that needs several sheets, formulas and formatting.

    InsteadWrite a real .xlsx workbook with openpyxl.

  • MistakeReconciling only the dates present in the first source.

    InsteadCompare the union of keys so rows missing from either side are reported.

Summary and key terms

  • A workbook holds worksheets of cells addressed in A1 notation; ranges such as B2:B8 cover blocks of cells.
  • Column letters are a base-26 numbering with no zero: A is 1, Z is 26, AA is 27.
  • CSV is best for exchanging plain tables; openpyxl reads and writes real .xlsx workbooks.
  • Formula cells read as formula text unless data_only=True; openpyxl never calculates.
  • Pivot tables are dictionaries of totals in Python, built with defaultdict or Counter.
  • Spreadsheet dates are serial numbers counted from 30 December 1899.
  • Neutralize outside text that starts with =, +, - or @, and reconcile sources over the union of their keys.
workbook
A spreadsheet file containing one or more worksheets.
worksheet
One grid of cells in a workbook.
A1 notation
Naming a cell by column letter and row number, such as C7.
range
A rectangular block of cells, such as B2:B8.
formula
A cell expression starting with = that the spreadsheet calculates.
pivot table
A summary grid of totals grouped by two or more fields.
openpyxl
A Python library for reading and writing Excel .xlsx files.
serial date
A date stored as a count of days from a fixed starting point.
formula injection
Untrusted text in an export that a spreadsheet runs as a formula.
reconciliation
Checking that two sources of the same figures agree.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What is the column number of AC?

    Show answer

    29

  2. What does openpyxl return for a formula cell when the workbook is opened normally?

    Show answer

    The formula text, such as =SUM(B2:B8).

  3. Why might Theo want formulas in his sheet but Anita want values in hers?

    Show answer

    Theo edits his numbers and wants live totals; Anita needs a fixed record that cannot change.

  4. Which date is serial number 1 when counting from 30 December 1899?

    Show answer

    31 December 1899.

  5. How should the customer name "=1+1" be written to a CSV export?

    Show answer

    As '=1+1, with a single quote in front.

  6. Why compare the union of the date keys when reconciling?

    Show answer

    So days missing from either source are reported as differences.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz