An async Python wrapper around gspread that gives every cell and row first-class async methods — read, write, clear, style, and delete without ever leaving your async/await flow.
gspread is a great library, but all its methods are synchronous and operate at the spreadsheet level. betterspread wraps it to give you:
| Feature | gspread | betterspread |
|---|---|---|
| Async-native API | ✗ | ✓ |
| Cell as a first-class object | ✗ | ✓ |
| Row as a first-class object | ✗ | ✓ |
Per-cell update / clear / style / delete |
✗ | ✓ |
Per-row update / clear / style / delete |
✗ | ✓ |
| Lazy connection (open on first use) | ✗ | ✓ |
| Load credentials from a file or a dict | ✗ | ✓ |
- Python ≥ 3.13
- A Google Cloud service account with the Sheets and Drive APIs enabled
pip install betterspreadOr with uv:
uv add betterspread- Go to the Google Cloud Console.
- Create or select a project, then enable the Google Sheets API and Google Drive API.
- Navigate to APIs & Services → Credentials → Create Credentials → Service Account.
- Open the service account, go to the Keys tab, and download a JSON key — save it as
credentials.json. - Open your Google Sheet, click Share, and give the service account's
client_emailEditor access.
credentials.jsonis listed in.gitignoreby default — never commit it.
import asyncio
from betterspread import Connection, Sheet
async def main():
con = Connection(credentials_path="./credentials.json")
sheet = Sheet(connection=con, sheet_name="My Spreadsheet")
tab = await sheet.get_tab("Sheet1")
# --- read ---
rows = await tab.values() # list[Row]
row = await tab.get_row(1) # Row (1-based)
cell = await tab.get_cell("B2") # Cell
print(cell) # Cell is a plain str subclass
print(row[0].label, row[0]) # "A" "hello"
# --- write ---
cell = await cell.update("world")
await row.update(["Alice", "30", "Engineer"])
# --- append ---
await tab.append(["Bob", "25", "Designer"])
# --- clear ---
await cell.clear()
await row.clear()
# --- delete ---
await row.delete()
await cell.delete()
asyncio.run(main())Authenticates with Google and holds the underlying gspread client.
Connection(
credentials_path: Path | str | None = None,
credentials_dict: dict | None = None,
)Exactly one of the two arguments must be supplied.
| Argument | Type | Description |
|---|---|---|
credentials_path |
Path | str |
Path to a service-account JSON key file. |
credentials_dict |
dict |
Service-account credentials as a dictionary (useful when loading from an environment variable). |
Raises ValueError if neither argument is provided.
# From a file
con = Connection(credentials_path="./credentials.json")
# From an environment variable
import json, os
con = Connection(credentials_dict=json.loads(os.environ["GOOGLE_CREDENTIALS"]))An async wrapper around a Google Spreadsheet. The connection is opened lazily — no network call is made until the first async method is called.
Sheet(
sheet_name: str,
connection: Connection,
folder_id: str | None = None,
)| Argument | Type | Description |
|---|---|---|
sheet_name |
str |
The exact title of the spreadsheet as it appears in Google Drive. |
connection |
Connection |
An authenticated Connection instance. |
folder_id |
str | None |
Optional Drive folder ID to scope the search. |
Opens the remote spreadsheet. Called automatically by all other methods — you only need this if you want to pre-warm the connection.
Returns the worksheet named tab_name as a Tab.
tab = await sheet.get_tab("Sheet1")await sheet.tabs(exclude_hidden=False) → list[Tab]
Returns all worksheet tabs in the spreadsheet.
all_tabs = await sheet.tabs()
visible_tabs = await sheet.tabs(exclude_hidden=True)Extends gspread's Worksheet with async helpers. Obtain a Tab via Sheet.get_tab() or Sheet.tabs().
Returns every row in the sheet as a list of Row objects.
rows = await tab.values()
for row in rows:
print(row)Returns the row at the given 1-based index.
first_row = await tab.get_row(1)Fetches a single cell by its A1 address. Both single-letter (B3) and multi-letter (AA10) column labels are supported.
| Argument | Type | Description |
|---|---|---|
cell_name |
str |
A1-notation address, e.g. "B3" or "AA10". |
render_option |
str |
"formatted" (default), "unformatted", or "formula". |
cell = await tab.get_cell("B2")
formula = await tab.get_cell("C1", render_option="formula")Appends a new row at the bottom of the sheet.
| Argument | Type | Description |
|---|---|---|
data |
list |
A flat list of values to write. |
get_row |
bool |
When True, returns the appended Row. |
await tab.append(["Alice", "30", "Engineer"])
# Get the appended row back
row = await tab.append(["Bob", "25"], get_row=True)Deletes one or more rows by their 1-based indices.
await tab.del_row(3) # delete row 3
await tab.del_row(3, end=5) # delete rows 3, 4, and 5Deletes a cell or a rectangular range, shifting the remaining cells.
| Argument | Type | Description |
|---|---|---|
start |
str |
Top-left cell address, e.g. "B2". |
end |
str | None |
Bottom-right cell address for a range. Defaults to start. |
shift |
str |
"up" (default) shifts rows up; "left" shifts columns left. |
await tab.del_cell("B2") # single cell, shift up
await tab.del_cell("B2", shift="left") # single cell, shift left
await tab.del_cell("A1", "C3") # delete a 3×3 rangeA list subclass where every item is a Cell. Obtain a Row from Tab.get_row(), Tab.values(), or Tab.append(get_row=True).
Every cell in a row carries:
- its column label (
"A","B","AA", …) - its 1-based row index
- a back-reference to its parent
Row
row = await tab.get_row(1)
print(row[0]) # cell value as a string
print(row[0].label) # "A"
print(row[0].row_index) # 1Overwrites the row's values starting from column start.
await row.update(["Alice", "30", "Engineer"])
await row.update(["30"], start="B") # only update column B onwardClears all values in the row.
await row.clear()Applies formatting to every cell in the row.
obj can be a Style instance or a raw gspread_formatting.CellFormat.
from betterspread import Style
await row.style(Style(bold=True, bg_color="#ffe599"))Appends one or more values to the end of the row.
await row.append_cell("new value")
await row.append_cell(["col1", "col2"])Reloads the row's values from the remote spreadsheet.
await row.refetch()Deletes the entire row from the sheet.
await row.delete()A str subclass — the cell's current value is the string itself. Obtain a Cell from Tab.get_cell() or by indexing a Row.
cell = await tab.get_cell("B2")
# or
cell = row[1]
print(cell) # "hello" (Cell is a str)
print(cell.label) # "B"
print(cell.row_index) # 2
print(cell.cell_index) # 1 (0-based)
print(repr(cell)) # <Cell B2='hello'>| Attribute | Type | Description |
|---|---|---|
label |
str |
Column label, e.g. "A" or "AA". |
row_index |
int |
1-based row number. |
cell_index |
int |
0-based column index within its parent row. |
tab |
Tab |
The Tab this cell belongs to. |
row |
Row | None |
Parent Row, or None when fetched via get_cell(). |
Writes a new value and returns an updated Cell instance.
| Argument | Type | Description |
|---|---|---|
value |
Any |
The new value to write. |
input_format |
str |
"raw" (default) or "user_entered". |
render_format |
str |
"formatted" (default), "unformatted", or "formula". |
cell = await cell.update("new value")
cell = await cell.update("=SUM(A1:A10)", input_format="user_entered")
Cellis immutable (it is astr).update()returns a newCell— reassign the variable to keep the updated value.
Clears the cell's value.
await cell.clear()Applies formatting to this cell.
obj can be a Style instance or a raw gspread_formatting.CellFormat.
await cell.style(Style(bold=True, text_color="#cc0000"))Deletes the cell and shifts neighbouring cells.
await cell.delete() # shift left (default)
await cell.delete(shift="up") # shift upA dataclass that builds a gspread_formatting.CellFormat from simple keyword arguments. Pass a Style to Cell.style() or Row.style().
Style(
bg_color: str = "#ffffff",
text_color: str = "#000000",
horizontal_align: str = "left", # "left" | "center" | "right"
vertical_align: str = "middle", # "top" | "middle" | "bottom"
bold: bool = False,
italic: bool = False,
strikethrough: bool = False,
raw: CellFormat | None = None,
)When raw is provided all other arguments are ignored and the CellFormat is passed through unchanged.
from betterspread import Style
# Highlight a header row
header_style = Style(
bg_color="#4a86e8",
text_color="#ffffff",
bold=True,
horizontal_align="center",
)
await row.style(header_style)
# Mark a cell as a warning
await cell.style(Style(bg_color="#fff2cc", italic=True))
# Use a pre-built CellFormat directly
from gspread_formatting import CellFormat, Color
await cell.style(Style(raw=CellFormat(backgroundColor=Color(1, 0.8, 0))))git clone https://github.com/shahriyar-alam/betterspread.git
cd betterspread
uv sync --group devuv run pytestTests cover only the functionality betterspread adds on top of gspread — no real network calls are made.
betterspread/
├── __init__.py # public exports
├── connection.py # Connection — gspread auth wrapper
├── sheet.py # Sheet — async Spreadsheet wrapper
├── tab.py # Tab — async Worksheet wrapper
├── row.py # Row — list subclass
├── cell.py # Cell — str subclass
├── style.py # Style — CellFormat builder
└── utils.py # shared helpers (run_in_executor, column utilities)
tests/
├── test_cell.py
├── test_connection.py
├── test_row.py
├── test_style.py
└── test_utils.py