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 | ✗ | ✓ |
| Automatic retry/backoff on rate limits | ✗ | ✓ |
Numeric cell accessor (cell.number) |
✗ | ✓ |
- Python ≥ 3.10
- A Google Cloud service account with the Sheets and Drive APIs enabled
pip install betterspreadOr with uv:
uv add betterspreadbetterspread runs gspread's blocking calls in a dedicated thread pool and automatically retries transient Google API errors (HTTP 429 and 5xx) with exponential backoff, so you rarely need to handle rate limits yourself.
- Thread pool size — set
BETTERSPREAD_MAX_WORKERSto override the default (min(32, cpu_count + 4)):
export BETTERSPREAD_MAX_WORKERS=8- 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, or if both are 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. |
number |
int | float | None |
Value as a number, or None if not numeric. |
tab |
Tab |
The Tab this cell belongs to. |
row |
Row | None |
Parent Row, or None when fetched via get_cell(). |
Because Cell is a str, numeric sheet values arrive as strings. Use .number to skip manual conversion:
qty = await tab.get_cell("B2")
print(qty) # "25" (a str)
print(qty.number) # 25 (an int)
total = qty.number * 1.1Writes 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 upBuilds a gspread_formatting.CellFormat from simple keyword arguments. Pass a Style to Cell.style() or Row.style().
Style(
bg_color: str | None = None,
text_color: str | None = None,
horizontal_align: str | None = None, # "left" | "center" | "right"
vertical_align: str | None = None, # "top" | "middle" | "bottom"
bold: bool | None = None,
italic: bool | None = None,
strikethrough: bool | None = None,
raw: CellFormat | None = None,
)Only the properties you pass are written — anything left as None is omitted, so applying a Style never clobbers formatting you did not set. Style(bold=True) makes a cell bold without touching its existing background, text color, or alignment.
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/shahriyardx/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