Aditya Sharma

IMPORTHTML vs IMPORTXML vs Apps Script: Which to Use in Sheets

On this page, 9 sections

Use IMPORTHTML when the data you want sits in a table or a list on the page. Use IMPORTXML when you need any other element or attribute, because it takes an XPath query. Use Apps Script when you need to control the request, read a status code, run on a schedule, or when the import functions will not return what you want.

The three differ less in what they can reach than in how much of the fetch you control. The two import functions give you a formula and no control. Apps Script gives you control and a set of published quotas.

I did not run any of the three in a live sheet for this post. The comparison comes from Google’s help pages and Apps Script reference, read on 11 October 2026, and the one script below was syntax-checked with Node.

What each function reads, according to Google

  • IMPORTHTML(url, query, index) reads a table or a list from an HTML page. The query is either “list” or “table”, and the index starts at 1. Lists and tables are numbered separately, so a page can have a table 1 and a list 1.
  • IMPORTXML(url, xpath_query, locale) reads XML, HTML, CSV, TSV, and RSS and ATOM feeds, and runs an XPath query against them. The locale is optional and defaults to the document locale.
  • IMPORTDATA(url) reads a CSV or TSV file at a URL. IMPORTFEED reads an RSS or ATOM feed.

All of them need the protocol in the URL, and the URL must be in quotation marks or come from a cell. Google’s own example for IMPORTXML is //a/@href, which returns link addresses, and for IMPORTHTML it is the fourth table on a Wikipedia page.

One more thing from Google’s data-reference page, read the same day: it warns that pulling data from another spreadsheet with IMPORTRANGE needs a round trip and can bring delays and intermittent connections. It says nothing about the other import functions. A script also has to fetch over the internet, so allow for a slow or failed request in any of the three approaches below.

Side by side

IMPORTHTMLIMPORTXMLApps Script UrlFetchApp
ReadsTables and listsAnything an XPath can selectAny HTTP or HTTPS response
InputURL, “table” or “list”, indexURL, XPath, optional localeA script you write
Control over the requestNone documentedNone documentedOptions such as followRedirects and muteHttpExceptions
Quota on the help or reference pageNone listedNone listed20,000 fetches a day (consumer), 100,000 (Workspace)

“None documented” is the right phrase, not “none”. The help pages do not list a quota or a request option, and I cannot tell you from them whether one exists.

The Apps Script limits that matter

Google’s Apps Script quotas page, last updated 3 September 2026, lists these for URL Fetch and for runtime:

  • URL Fetch calls: 20,000 a day for consumer accounts, 100,000 a day for Google Workspace accounts.
  • URL Fetch response size: 50 MB per call.
  • URL length: 2 KB per call.
  • Script runtime: 6 minutes per execution.
  • Triggers total runtime: 90 minutes a day for consumer accounts, 6 hours a day for Workspace.

The runtime limit is the one that bites first. If you pause 2 seconds between requests to be polite to the site, 6 minutes is 360 seconds, and 360 divided by 2 is 180 fetches per run at the very most, before counting the time each fetch takes.

So the daily fetch quota of 20,000 is not your real ceiling. Your real ceiling is how many runs you can schedule within the trigger runtime.

A script that checks status before it trusts the body

This is the part the import functions cannot do. It reads a URL, throws on anything other than a 200, and pauses between requests. The reference page documents both options used here: followRedirects defaults to true, and muteHttpExceptions defaults to false, so setting it to true lets the script read a failing status code instead of dying on it.

function fetchTitle(url) {
  const res = UrlFetchApp.fetch(url, { muteHttpExceptions: true, followRedirects: true });
  const code = res.getResponseCode();
  if (code !== 200) throw new Error("HTTP " + code);
  const m = res.getContentText().match(/<title[^>]*>([^<]*)<\/title>/i);
  return m ? m[1].trim() : "";
}

function writeTitles() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const urls = sheet.getRange("A2:A" + sheet.getLastRow()).getValues().flat().filter(String);
  const out = [];
  for (const u of urls) {
    try { out.push([fetchTitle(u)]); } catch (e) { out.push([e.message]); }
    Utilities.sleep(2000);
  }
  sheet.getRange(2, 2, out.length, 1).setValues(out);
}

I syntax-checked this with node --check, which passes. I did not run it against a live sheet. Put URLs in column A from row 2 and the titles land in column B.

The same job, three ways

Say a page has a pricing table and you want it in a sheet.

With IMPORTHTML you ask for the table by position:

=IMPORTHTML("https://example.com/prices", "table", 2)

The index counts tables “as defined in the HTML source”, in Google’s words, so the formula points at the second table in the source. If the site adds a table above it next month, the index now points at the wrong one and the formula still returns data. That is the quiet failure with IMPORTHTML: it keeps working on the wrong thing.

With IMPORTXML you ask for the element by what it is, not where it sits:

=IMPORTXML("https://example.com/prices", "//table[@id='pricing']//tr")

An attribute test like @id='pricing' survives a new table appearing above it, as long as the id stays. Whether the id stays is up to the site. I used single quotes inside the query because the query itself sits in double quotes.

With Apps Script you fetch the page, check the status code, and decide what to do with the body. That is the only one of the three where a 403 or a 500 shows up as a number you can act on, instead of an error in a cell.

None of the three fixes a page that does not contain your data in the response. That is a source problem, and the answer is a different source: a feed, a sitemap, or the JSON request the page makes itself.

How to choose

  1. Is the data in a table or a list? Use IMPORTHTML first. It is the least to write.
  2. Is it one element or an attribute? Use IMPORTXML with a short XPath. Test it with //title first, as in IMPORTXML not working in Google Sheets.
  3. Is it CSV, TSV or a feed? Use IMPORTDATA or IMPORTFEED rather than parsing HTML.
  4. Do you need the status code, a pause between requests, or a scheduled run? Use Apps Script.
  5. Does the formula return nothing while the page looks fine in a browser? Check the raw response before you rewrite anything. If the data is not in it, no import function will reach it. The reference describes UrlFetchApp as issuing HTTP and HTTPS requests and receiving responses; it does not say it runs a page’s scripts either.

What I could not verify

I could not verify how Sheets refreshes the import functions, or any limit on how many you can have in one sheet, because the help pages are silent. I also did not benchmark the three against each other, so I make no speed claim.

If your goal is a list of pages rather than their contents, a sitemap is the cleaner source. I wrote that one up in extracting links from a sitemap in Sheets, and the wider starting point is getting data from a website into Google Sheets.

Do this today

Take the one import you rely on most and write down which of the five questions above it answers. If the answer is question 4, copy the script, put five URLs in column A, and run it once to see the status codes your formula was hiding.

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.