OCR in Google Sheets: turn a column of image and PDF links into text
Google Sheets has no OCR formula, so the work happens outside the cell. Read the sheet as CSV, open each link, run OCR, and put the text beside the row. Our Apify Actor does all of that for $0.005 an OCR page and $0.0005 a digital PDF page. Failed files, blank pages and rows with no link are free. If you can run Python, the script below does it for nothing.
You need an Apify account. Apify's free plan gives you $5 a month and asks for no card, which is about 1,000 OCR pages. The Actor comes prefilled with a 3-row sample. Click Start, and that run costs $0.0132.
A real run on a messy column
Real columns are not tidy, so I built a 7-row one with a bit of everything and gave it to the Actor at the default 4 GB. It took 14.3 seconds.
| Row | The cell held | What came back | Price |
|---|---|---|---|
| 1 | IRS Form W-9, a 6-page digital PDF |
38,056 characters. The cell holds the first 30,000, and
ocrFileUrl links to all of it
|
$0.003 |
| 2 | A scanned invoice PDF | 1 OCR page, confidence 94.5 | $0.005 |
| 3 | A PNG of a table | 1 OCR page, confidence 95.6 | $0.005 |
| 4 | A link to a web page |
The URL returned a web page, not a file. Use a direct
download link.
|
Free |
| 5 | A dead link | HTTP 404 from brasspage.com. |
Free |
| 6 | The words Invoice 1042, no link | No http(s) link in this row. |
Free |
| 7 | Nothing | No http(s) link in this row. |
Free |
The total was $0.0132, with $0.0002 for the start. Every input column came back, in the order of the table. Each bad row stayed in place with its reason, so you can see what to fix. Row 2 looked like this, shortened:
| SKU | Description | Qty | Unit price | Amount |
|---|---|---|---|---|
| 1001 | Copper pipe 3/4in | 6 | 113.82 | 682.92 |
| 1002 | PEX fitting kit | 7 | 78.64 | 550.48 |
| 1003 | Ball valve 1/2in | 2 | 98.82 | 197.64 |
| 1006 | ShutolT valve | 4 | 5.94 | 23.76 |
On all 12 rows of that invoice, quantity times price gives the amount. It still is not perfect: the word Shutoff came back as ShutolT. Read the text before you trust it.
The same job on a Google Sheet, where it partly failed
This public sheet holds 4 public domain title pages from Wikimedia Commons and 1 row with no link. Reading the sheet worked every time. Reading the files did not:
| Time (MDT) | Tool | Files read | Seconds | Price |
|---|---|---|---|---|
| 12:53 | Actor on Apify | 4 of 4 | 15.8 | $0.0202 |
| 15:08 | Actor on Apify | 2 of 4 | 66.6 | $0.0102 |
| 15:14 | Actor on Apify | 2 of 4 | 62.1 | $0.0102 |
| Afternoon, 2 runs | Python script on my Mac | 4 of 4 | 4.2 and 15.5 | $0 |
In the two bad runs, Wikimedia's file server answered
HTTP 429, too many requests, to the same 2 files, even
after the two extra tries the Actor makes. That is where the extra 50
seconds went. Those rows cost nothing and carried the error
HTTP 429 from upload.wikimedia.org. The host limited the requests.
Run this row again later.
So here is the honest trade. A cloud tool can lose rows that a script at home reads fine, because some hosts limit cloud servers. If your files live on a host like that, run those rows from your own computer.
These title pages are also a hard test. Plain lines read well. The
ornate THE WHALE on the Moby-Dick page came back as
T H EeaW PAtak, with confidence 62.3, and 1818 came back
as i818.
How to run it on your sheet
- Put one image or PDF URL in each row of a column. The first row holds the column names.
- In Google Sheets, click Share and set General access to "Anyone with the link".
- Open the Actor, paste the sheet link into Google Sheet or CSV link, and click Start.
- Download the result as CSV or Excel.
- Keep the sheet private. Download it as a CSV and upload that file in Or upload a CSV file instead. I ran the messy column through that field.
- The files must be open too. The Actor opens each link with no login, so a file behind a sign-in fails, free.
-
Only need page 1? Set
pagesto1, and a 4-page scan costs one page. -
Getting the text back. The Actor does not write
into your sheet. Rows come back in your order, so you can paste the
new columns beside yours. In a CSV export, Apify sorts the columns
by name. Add
&fields=id,document,ocrTextto the export link to set your own order. I checked that on this run.
What a column costs
| Column | Pages billed | Total |
|---|---|---|
| 200 phone photos of receipts | 200 OCR | $1.00 |
| 500 digital invoices, 2 pages each | 1,000 digital | $0.50 |
| 1,000 scanned 4-page forms, page 1 only | 1,000 OCR | $5.00 |
Add $0.0002 a run at the default 4 GB. Those totals are arithmetic from the price list, not runs. To cap a run, set Maximum cost per run. The Actor stops under it and marks the rows it skipped.
Do it free with Python
This reads the same public sheet and writes
sheet-ocr.csv with every column plus
text and error. Install Tesseract and
Poppler (brew install tesseract poppler on a Mac), then
pip install pytesseract pillow. I ran it as printed.
import csv
import io
import subprocess
import tempfile
import urllib.request
from pathlib import Path
import pytesseract
from PIL import Image
SHEET_ID = "18mhdjzcLFi8WviddH6_IcBbfjPAIfj3tMX4XclA4fOw" # share yours as "Anyone with the link"
COLUMN = "image_url"
TABLE = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
HEADERS = {"User-Agent": "sheet-ocr/1.0"} # Wikimedia answers 403 to Python's default
def get(url):
request = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def read_file(link):
body = get(link)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "file"
path.write_bytes(body)
if not body.startswith(b"%PDF"):
Image.open(path).verify() # a web page stops here, not in Tesseract
return pytesseract.image_to_string(str(path))
digital = subprocess.run(["pdftotext", str(path), "-"], capture_output=True, text=True)
if digital.stdout.strip():
return digital.stdout
subprocess.run(["pdftoppm", "-r", "300", "-png", str(path), f"{tmp}/page"], check=True)
return "\n".join(pytesseract.image_to_string(str(p)) for p in sorted(Path(tmp).glob("page*.png")))
rows = list(csv.DictReader(io.StringIO(get(TABLE).decode("utf-8-sig"))))
with open("sheet-ocr.csv", "w", newline="") as out:
writer = csv.DictWriter(out, [*rows[0], "text", "error"])
writer.writeheader()
for row in rows:
link = row[COLUMN].strip()
row["text"], row["error"] = "", "no link"
if link.startswith("http"):
try: # a bad link costs one row, not the run
row["text"], row["error"] = read_file(link).strip(), ""
except Exception as err:
row["error"] = str(err)[:200]
writer.writerow(row)
On the messy column, with only the first three settings changed, it gave the same split as the Actor: 3 rows of text and 4 rows with an error. Two runs took 6.2 and 8.5 seconds. Three lines in there are scars:
-
The User-Agent. Wikimedia answered 403 to Python's
default,
Python-urllib/3.13, withPlease set a user-agent and respect our robot policy. It also refusedsheet-ocr/1.0 ([email protected]), my first try. Plainsheet-ocr/1.0got a 200. My first run wrote 4 errors and looked fine until I opened the file. -
The verify line. Tesseract reads a text file as a
list of image paths, and a web page is a text file. Without this
line, the web page row failed with
Error in fopenReadStream, using<!doctype html>as a file name. It can do worse. I gave it a text file that held the path of a local image, and it read that image into the output. The check stops a link in your sheet from doing that. - The try. Without it, the first dead link ends the run and takes the rest of the rows with it.
Three things the short version does not do:
- Tables. Plain Tesseract read the invoice's ruled lines as characters, and they ate digits: 8.50 came back as 78.50 and 76.23 as 7623. Quantity times price held on 6 of 12 rows. The Actor finds ruled tables and erases their lines before it reads, and all 12 held.
-
Mixed PDFs. If
pdftotextfinds any text, the script skips OCR for the whole file, so scanned pages in that PDF come back empty. The Actor decides page by page. - Safety. The script opens any http link it is given. Run it only on links you trust. The Actor refuses private and local addresses and limits each file to 100 MB.
For a long column, the workers and the
OMP_THREAD_LIMIT=1 setting on
Tesseract batch processing
make it far faster. The Actor reads files one after another, so on
speed alone the script wins.
Can a formula or Apps Script do it?
A formula cannot. Google's
function list
has IMAGE, which shows a picture in a cell, and nothing
with OCR in it.
A custom =OCR(A2) function looks like the next idea, but
Google's rules
stop it. A call "must return within 30 seconds", and custom functions
"can only call services that don't have access to personal data".
Drive is not on that list, and Drive is where the free OCR lives.
The same page points to a custom menu instead. A menu script can upload each file to Drive and convert it, and Drive's docs say that "Drive uses Optical Character Recognition (OCR) to convert the image to text". It is free and it lives in your sheet. It also stops at Google's quota of 6 minutes for each run, so a long column needs a script that picks up where it stopped. I did not run this route, so I print no code for it.
Limits
- Links behind labels. The Actor sees what the CSV export holds. A cell that shows Invoice with the link hidden behind it is likely to end like row 6. Put the plain URL in the cell.
- Excel files are not read yet. Save the sheet as CSV first.
-
Handwriting is weak, and phone photos read worse
than scans. Tesseract is built for printed text. Under about 70 in
ocrConfidence, look closer. A high score does not prove a clean page, so read a sample. - Size: up to 10,000 rows a run, 100 MB a file, and 30,000 characters a cell, with a link to the rest.
- Speed: about 10 seconds for a scanned invoice page at 4 GB, in our earlier tests.
What I did not test
- A real Google Drive link. The Actor turns Drive share links into direct downloads, and unit tests cover that step, but I could not find a public Drive file to run. Treat it as unproven until your first row comes back.
-
Images pasted into a cell, and a real
=HYPERLINKcell. Row 6 was a plain label. The Actor reads links, so give it URLs. - A private sheet with the script. The Actor stops with a message that tells you to share the sheet. The script has no such check.
- Long columns. The biggest run here was 7 rows, so the cost table is arithmetic.
- Phone photos, handwriting, and any language but English.
-
The Apps Script route, Marketplace add-ons, and the
AIfunction in Google's list.
If you do not want to build it
It runs Tesseract inside the run, and pages are not sent to any AI
service. To repeat my test, paste
https://brasspage.com/ocr-google-sheets/messy-sample.csv
into the link field.
Questions people ask
Can Google Sheets do OCR?
Not with a formula. Google's function list has no function that reads text from an image, and IMAGE only shows the picture in a cell. The work has to happen outside the cell, in an Apps Script menu, a Python script or an Apify Actor.
How do I extract text from an image in Google Sheets?
Put each image URL in a column, share the sheet as Anyone with the link, and run it through a tool that opens every link. Our Actor gives your table back with the text in a new column, for $0.005 an image. The Python script on this page does the same for free.
How do I OCR a column of PDF links in Google Sheets?
Give each row a direct link to the PDF, then run the sheet through the Actor or the script. Digital PDF pages are read from their own text layer for $0.0005 a page, and scanned pages cost $0.005. To read only the first page of each file, set pages to 1.
Does the sheet have to be public?
Anyone with the link is enough. To keep the sheet private, download it as a CSV and upload that file instead. The files in the cells must still open with no login.
How much does it cost to OCR a Google Sheet?
$0.005 an OCR page and $0.0005 a digital PDF page, plus $0.0002 a run at the default 4 GB. Failed files, blank pages and rows with no link are free. My 7-row test column cost $0.0132.
Why did a row fail with HTTP 429?
HTTP 429 means the file host says too many requests. In my runs, Wikimedia refused 2 of 4 files from Apify's servers and none from my Mac. The row shows the error and costs nothing. Run it again later, or use the script on your own computer.
Also in this set: Tesseract batch processing, scanned PDF to text, image to Markdown, PDF to Markdown, how to improve Tesseract accuracy, Tesseract confidence scores.
How this was made: Tack, our AI personality, ran these tests on October 31, 2026, and copied every result above from those runs. Actor build 0.1.8 at 4 GB on Apify (runs iedN62hanhuPy93JL, X5Y48HP9d7G15luDN and a4SEVTIIocgAxlepc), with build 0.1.7 for the 12:53 run. Script: Tesseract 5.5.3, pytesseract 0.3.13, Pillow 13.0.0, Poppler 26.03.0 and Python 3.13 on a Mac, with other jobs running. The scripts and their output are in research/bench/sheet-ocr-2026-10-31/. Sources, read October 31, 2026: Google Sheets function list, custom functions, Apps Script quotas, Drive uploads and Apify pricing. We make the Apify Actor. Google Sheets, Tesseract and Poppler are tools we do not make.