Add Borders to a Range with openpyxl
Borders make a table readable: a light grid to guide the eye, a line under the header, a box around the whole table and the classic double rule above a total. In Excel you select a range and click "Outside Borders". openpyxl has no such command — borders belong to individual cells, and each cell's border is replaced wholesale on every assignment. That is why border code so often erases itself. This guide, part of Styling Excel Cells with openpyxl, builds a few small helpers that apply borders to ranges the way Excel does, and checks the result by rendering the workbook.
Prerequisites
pip install openpyxl
The sample table:
from copy import copy
from openpyxl import Workbook
from openpyxl.styles import Border, Font, Side
from openpyxl.utils.cell import range_boundaries
wb = Workbook()
ws = wb.active
ws.title = "Sales"
for row in [("Region", "Q1", "Q2", "Total"), ("North", 120, 140, "=B2+C2"),
("South", 80, 90, "=B3+C3"), ("East", 40, 55, "=B4+C4"),
("Total", "=SUM(B2:B4)", "=SUM(C2:C4)", "=SUM(D2:D4)")]:
ws.append(row)
Step 1 — Borders are four Sides, replaced together
A Border holds four Side objects — left, right, top, bottom — each with a style and a colour. Assigning a new Border to a cell replaces all four at once, and style objects cannot be edited in place:
thin = Side(style="thin", color="CDD5E6")
ws["A1"].border = Border(left=thin, right=thin, top=thin, bottom=thin)
ws["A1"].border = Border(bottom=Side(style="medium"))
print(getattr(ws["A1"].border.left, "style", None), ws["A1"].border.bottom.style) # None medium
try:
ws["A1"].border.left = thin
except AttributeError as exc:
print(exc) # Style objects are immutable and cannot be changed.Reassign the style with a copy
The second assignment wiped the left, right and top edges — the classic self-erasing border. The fix is a helper that starts from the cell's current border and replaces only the edges you name.
Step 2 — A helper that keeps existing edges
set_edges reads the cell's current border, swaps in the given sides and assigns the result:
def set_edges(cell, **sides):
current = cell.border
cell.border = Border(
left=sides.get("left", current.left), right=sides.get("right", current.right),
top=sides.get("top", current.top), bottom=sides.get("bottom", current.bottom),
)
def grid(ws, ref, side):
for row in ws[ref]:
for cell in row:
set_edges(cell, left=side, right=side, top=side, bottom=side)
def outline(ws, ref, side):
min_col, min_row, max_col, max_row = range_boundaries(ref)
for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col):
for cell in row:
edges = {}
if cell.row == min_row: edges["top"] = side
if cell.row == max_row: edges["bottom"] = side
if cell.column == min_col: edges["left"] = side
if cell.column == max_col: edges["right"] = side
if edges:
set_edges(cell, **edges)
outline touches only the cells on the edge of the range and only the edges that face outward, which is exactly what Excel's "Outside Borders" does. Because every change goes through set_edges, the helpers can be applied in any order without erasing each other.
Step 3 — Layer the table's borders
Apply the light grid first, then the rules that should sit on top of it, then the outline last so nothing overwrites it:
medium = Side(style="medium", color="1E293B")
double = Side(style="double", color="1E293B")
grid(ws, "A1:D5", thin)
for cell in ws[1]:
set_edges(cell, bottom=medium)
for cell in ws[5]:
set_edges(cell, top=double)
outline(ws, "A1:D5", medium)
wb.save("bordered.xlsx")
print(ws["A1"].border.left.style, ws["B3"].border.left.style, ws["B5"].border.top.style)
# medium thin double
Rendered, the table has a pale grid, a dark rule under the header, a double rule above the totals and a dark frame around everything. The double line above a total row is the accounting convention for "this is the sum of the figures above", and readers of financial reports look for it.
Step 4 — Bold headers without losing the font
The same immutability applies to fonts, with a subtler trap: Font(bold=True) creates a whole new font with no name or size, so the cell falls back to the application default — in LibreOffice's rendering, a serif face. Copy the existing font and change one attribute instead:
def make_bold(cell):
font = copy(cell.font)
font.bold = True
cell.font = font
for cell in list(ws[1]) + list(ws[5]):
make_bold(cell)
wb.save("bordered.xlsx")
print(ws["A1"].font.name, ws["A1"].font.sz, ws["A1"].font.b) # Calibri 11.0 True
copy produces an editable duplicate of the style object; after changing it, assign it back. The same pattern works for fills, alignment and protection. For a report with many styled ranges, a named style is more efficient than per-cell copies — see Apply a Reusable Style Theme Across an Excel Report.
Step 5 — Merged cells
A merged range is drawn from its individual cells, so an outline around a merged title needs the edges on every cell in the block — setting a border on the top-left cell alone leaves the rest of the frame missing:
ws.insert_rows(1)
ws["A1"] = "Quarterly sales"
ws.merge_cells("A1:D1")
outline(ws, "A1:D1", medium)
wb.save("bordered.xlsx")
print([ws.cell(1, c).border.top.style for c in range(1, 5)]) # ['medium', 'medium', 'medium', 'medium']
outline already walks every cell of the range, so it handles merged blocks without special cases. Inserting a row shifts the cells and their borders down with them, so the table keeps its formatting below the new title. Merging itself is covered in Merge Cells and Centre a Report Title with openpyxl.
Step 6 — Border a table written by pandas
In most reports the table comes from DataFrame.to_excel, and the borders go on afterwards. The range follows from the frame's shape, so compute it instead of hard-coding it — the report may have four rows today and forty next month:
import pandas as pd
from openpyxl.utils import get_column_letter
report = pd.DataFrame({"Region": ["North", "South", "East"], "Q1": [120, 80, 40], "Q2": [140, 90, 55]})
with pd.ExcelWriter("pandas_bordered.xlsx", engine="openpyxl") as writer:
report.to_excel(writer, sheet_name="Report", index=False, startrow=1)
sheet = writer.sheets["Report"]
first_row = 2 # header row after startrow=1
last_row = first_row + len(report)
last_col = get_column_letter(len(report.columns))
table_ref = f"A{first_row}:{last_col}{last_row}"
grid(sheet, table_ref, thin)
for cell in sheet[first_row]:
set_edges(cell, bottom=medium)
outline(sheet, table_ref, medium)
print(table_ref) # A2:C5
startrow is zero-based in pandas and leaves row 1 free for a title, so the header lands in row 2. The current pandas release writes a plain header — no bold, no border — so the grid and the header rule supply all the structure; make the header bold with the make_bold helper from Step 4. Older pandas versions styled the header themselves, bold with thin borders, and the helpers simply layer on top of that. Writing the table and styling it in the same ExcelWriter block means the file is saved once, with borders included.
Common pitfalls
- Borders on the neighbour. The line between A1 and A2 can come from A1's bottom or A2's top. Pick one convention — this guide always sets both through the grid — or lines appear twice as thick in some viewers.
- Borders that do not print. Very light colours such as
E7EBEFlook fine on screen and vanish on paper. Check a print preview, or use a slightly darker grey for grids in reports that are printed. - Styling huge ranges cell by cell. Hundreds of thousands of
set_edgescalls are slow. Style the header and totals explicitly and rely on a table style or banding for the body. - Colours with a #.
Side(color="#CDD5E6")raises "Colors must be aRGB hex values"; pass the six hex digits without the hash, or an 8-digit ARGB value.
Conclusion
openpyxl borders are per cell and replaced as a whole, so build them through a helper that copies the current border and swaps only the named edges. With set_edges, grid and outline you can layer a light grid, a medium header rule, a double total rule and an outer frame in any order, border merged blocks cell by cell, and — by the same copy-then-assign pattern — make text bold without losing its font.
Frequently asked questions
How do I put a border around a range with openpyxl? openpyxl has no range-level border, so loop over the cells and set only the outer edges — top on the first row, bottom on the last, left on the first column and right on the last — while keeping each cell's other edges.
Why did my border disappear when I added another one? Assigning cell.border replaces all four edges. Setting a bottom border after a grid removes the grid's left, right and top lines. Build the new Border from the existing one, changing only the edges you want.
Why do I get "Style objects are immutable"? openpyxl style objects cannot be changed in place, so cell.border.left = Side(...) fails. Create a new Border — or copy the existing one with the changed edge — and assign it to cell.border.
How do I border merged cells? Set the border on every cell covered by the merge, not just the top-left one. Excel draws each edge from the cell that sits on it, so the outer cells of the merged block need the outline edges.
Related
- Up one level: Styling Excel Cells with openpyxl — fonts, fills, borders and layout.
- Add Alternating Row Colors with openpyxl — banding as an alternative to a grid.
- Wrap Text and Align Cells with openpyxl — the other half of a tidy table.
- Copy Cell Styles from One Cell to Another with openpyxl — reusing a styled cell's look.
More on cell styling & layout
- How-toWrite Rich Text with Mixed Formats in One Cell with xlsxwriter
- How-toCopy Cell Styles from One Cell to Another with openpyxl
- How-toAuto-Fit Column Widths When Writing with pandas
- How-toExport a Styled pandas DataFrame to Excel
- How-toGenerate a Staff Rota in Excel with Python
- How-toAdd a Table of Contents Sheet with Hyperlinks in Excel