# HubSpot Google Sheets Integration: Refresh Data Without Duplicate Rows

A reliable HubSpot Google Sheets integration needs an explicit direction, a stable record key and a refresh rule. Use a HubSpot workflow for a bounded operational feed, a scheduled extraction for a complete reporting snapshot, or the Sheets assistant for contact work inside the spreadsheet. Keep reporting formulas separate from imported data, and verify changed, missing and duplicate records before trusting the output.

Canonical URL: https://www.martechdevs.com/blog/hubspot-google-sheets-integration

By [Jafrin Akter Alo](https://www.martechdevs.com/authors/jafrin-akter-alo)

Data Engineer and Co-founder at [Martechdevs](https://www.martechdevs.com) and [Growth and Analytics Partners](https://www.growthandanalytics.com)

[LinkedIn](https://linkedin.com/in/jafrinalo)

Last updated: 2026-10-27

Next step: [Salesforce to HubSpot migration: plan, field mapping and cutover checklist](https://www.martechdevs.com/blog/salesforce-to-hubspot-migration)

## Which HubSpot and Google Sheets integration do you actually need?

Choose the integration by what must move and when. Sending a contact change into a sheet, importing contacts into HubSpot and rebuilding a complete reporting dataset are different jobs.

| Required job | Route to evaluate | What to verify |
| --- | --- | --- |
| Send eligible HubSpot record changes into a sheet | HubSpot Google Sheets workflow action | Trigger coverage, matching key and duplicate handling |
| Work with contacts from inside Sheets | HubSpot Assistant for Google Sheets | Contact scope, permitted writes and account limits |
| Refresh a complete reporting population | Scheduled connector or owned API extraction | All pages, stable scope, successful publication and removal policy |
| Analyze a single point-in-time dataset | Manual HubSpot export | Selected view, properties and export cutoff |
| Combine CRM, billing and advertising with retained history | Warehouse, with Sheets as a reporting consumer if useful | Shared models, historical coverage and restricted access |

HubSpot's workflow integration sends selected record properties to new or existing rows when an action runs. That is an event-driven action, not a declaration that the sheet contains every current CRM record. [Google Sheets Workflows for HubSpot](https://ecosystem.hubspot.com/marketplace/listing/google-sheets-228553).

HubSpot Assistant for Google Sheets is a separate extension for contact work and contact import into HubSpot. Its product page describes cleanup, contact context and a Sheets-to-HubSpot import flow. Do not assume that installing it provides a scheduled, complete deal-history export. [HubSpot Assistant for Google Sheets](https://www.hubspot.com/free-business-tools/hubspot-for-google-sheets).

For occasional analysis, a manual export may be sufficient. HubSpot exports current property values and associations from the chosen records; activities use separate export or API routes. Label the resulting snapshot with its scope and extraction cutoff in the workbook. [HubSpot record exports](https://knowledge.hubspot.com/import-and-export/export-records).

Use [CRM-to-warehouse integration](https://www.martechdevs.com/blog/crm-to-data-warehouse) when shared history and several reporting consumers justify a maintained data layer. Our [warehouse and reporting implementation service](https://www.martechdevs.com/data-warehouse-cdp) can scope the smallest reliable route for the decision the sheet supports. If the sheet mainly feeds a dashboard, compare it with a direct route: [connect HubSpot to Looker Studio](https://www.martechdevs.com/looker-studio-implementation#connect-hubspot).

## What should one row represent?

One row should represent one declared business entity at one declared point in time. For a current deal report, use one row per deal; for a daily snapshot, use one row per deal per snapshot date. Mixing those grains produces misleading counts even when every write succeeds.

Prefer the HubSpot record ID within its object and account scope. An email address can change, and a contact can have no email yet still belong in a legitimate report. Preserve record IDs as strings. If data from several accounts or objects shares a dataset, retain the account and object alongside the ID.

HubSpot's Contacts API supports record-ID retrieval and exposes record properties separately from associations. Choose the fields and relationships the report actually needs rather than relying on default returned fields. [HubSpot Contacts API](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide).

| Column | Example meaning | Control |
| --- | --- | --- |
| `portal_id` | The approved HubSpot account | Keep as text; prevent cross-account mixing |
| `object_type` | Contacts or deals | One object grain per raw tab where practical |
| `record_id` | Source record identifier | Required and unique within that scope |
| Selected business fields | Owner, lifecycle stage or deal amount | Explicit mapping and type policy |
| Source modified time | Last relevant source change | Keep separately from import completion |
| Run ID and completion time | The extraction that supplied the row | Supports recovery and freshness investigation |

For workflow output from a single account and object, the lookup column can contain the record ID alone. For a combined dataset, create and validate a scoped key before matching. A row number is a location, not an identity: sorting the sheet must not change which customer receives an update.

Keep association data at its own grain. If one deal has three associated contacts, a flattened deal/contact export can create three rows for one deal. Summing the repeated deal amount then triples pipeline value. Use an association table or aggregate the required contact information before joining it into a one-row-per-deal report.

## How should you configure the native workflow action?

Configure a keyed update for a current-state sheet and test it on a small controlled set before broad enrollment. Use a separate feed for append-only event records if that is the intended output.

HubSpot lists the action for eligible Professional or Enterprise subscriptions. Its setup requires labeled headers beginning at A1 and a spreadsheet within the integration's five-million-cell limit. The connected Google identity must be able to access the selected file. [Workflow prerequisites and spreadsheet requirements](https://knowledge.hubspot.com/workflows/add-data-to-google-sheets-with-workflows).

1. Write the eligible population and the fields that may change. Decide whether the sheet is authoritative for any field; a reporting copy usually should not be.
2. Create a raw tab with the required identifiers and approved columns. Keep analyst calculations and comments in separate tabs keyed by record ID.
3. Connect the Google identity intended to own the integration, then select the exact spreadsheet and tab in the workflow action.
4. Choose the update action, match the lookup column to the record-ID property and map the fields to update. Decide explicitly whether an unmatched record should create a row.
5. If creation is allowed, include the key itself in the mapped output. Otherwise a newly inserted row may not be identifiable on the next run.
6. Enroll a controlled fixture, inspect the result and test the relevant changes again before enabling the intended population.

HubSpot's update-action design uses a lookup column and matching property, with an optional create-if-unmatched setting. [HubSpot's update-action explanation](https://community.hubspot.com/t/now-live-update-existing-rows-in-google-sheets-from-workflows/50512).

Two behaviors deserve explicit tests: when duplicate matches exist, the native action updates only the first matching row; when no match exists, it creates nothing unless the create option is enabled. New rows contain the configured output properties. HubSpot also warns that request timeouts can produce duplicate rows. [Matching and timeout behavior](https://knowledge.hubspot.com/workflows/add-data-to-google-sheets-with-workflows).

Keep one controlled writer for the raw dataset. If two integrations both decide that a key is missing and append it, a lookup-based design can still create duplicates. An upsert setting is not proof of exactly-once delivery under concurrent writes and retries.

## Why do later HubSpot changes fail to appear?

Later changes fail to appear when no successful write runs for that change, even if the first enrollment populated the sheet correctly. Inspect the workflow's enrollment and action history before changing the spreadsheet mapping.

HubSpot workflows enroll a record once by default. Re-enrollment must use a supported trigger; a record cannot re-enroll while it is already enrolled. Merely turning re-enrollment on does not retroactively import all eligible records. [HubSpot re-enrollment rules](https://knowledge.hubspot.com/workflows/add-re-enrollment-triggers-to-a-workflow).

For each field the report treats as current, test a realistic transition. An owner change, stage change, cleared value and new record are separate cases. A rule that enrolls on first qualification might never run when a qualified contact's owner changes later.

Use a short feed workflow with no unrelated customer messages or long waits. Re-enrollment reruns workflow actions from the start, so combining data refresh with outreach requires additional care. The feed should not send an email merely because an analyst needs an updated row. [Re-enrollment action behavior](https://knowledge.hubspot.com/workflows/add-re-enrollment-triggers-to-a-workflow).

If the requirement is “all eligible records refreshed before the morning meeting,” evaluate a scheduled complete extraction. Record-triggered updates can support operational distribution, but the reporting requirement also needs initial population, missed-change repair and records leaving the selected scope.

## How do you design a complete scheduled refresh?

Build the new dataset separately, validate completeness and publish it only after the extraction succeeds. Keep the previous accepted dataset available when a run fails.

A connector's “refresh” label is insufficient. Establish whether it replaces a range, appends records or updates by key, and whether it removes records absent from the new extraction. A failed extraction must not turn a partial result into the new authoritative report.

1. Fix the account, object, selected fields and filters for the run.
2. Retrieve every page and retain the run's scope, start and completion evidence.
3. Normalize IDs, timestamps and selected values without silently changing identity.
4. Reject duplicate keys, missing required columns and unexplained population changes.
5. Write a candidate dataset into a controlled staging area.
6. Publish the accepted version and its completion marker; make the report read that version.
7. Retain the prior accepted version until the recovery and retention policy permits cleanup.

For the Contacts API's list route, HubSpot returns paginated results and supplies an `after` cursor for subsequent requests. The page size is not the size of the account. Use the selected API version's continuation contract, and do not invent successive record IDs as page offsets. [Contact pagination](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide).

A multi-page CRM extraction is not automatically a transactionally consistent database snapshot. Records can change during the run. For a strict cutoff requirement, establish the connector's consistency behavior and reconcile late changes; for an operational view, record the extraction interval and its accepted tolerance.

For an owned API route, use authentication supported by that implementation. HubSpot is replacing creation of legacy private apps with Service Keys for applicable system integrations while existing private apps continue working. Do not follow an old tutorial that assumes every account can create a legacy token. Verify scopes and connector compatibility before committing to the route. [HubSpot authentication change](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset).

## How can you compare two snapshots without deleting the wrong rows?

Compare accepted snapshots by scoped record key and treat absence as a review category until its meaning is established. A missing record might have been deleted, merged, filtered out or hidden by changed permissions.

This Python 3 helper compares two already-normalized, complete snapshots of the same account, object and filters. It returns proposed insert, changed and absent-key sets; it does not write to Google Sheets or delete HubSpot records. Supply a scope identifier covering all selection rules, and pass completion only after the extraction's checks succeed.

```python
def snapshot_diff(previous, current, *, previous_scope, current_scope,
                  previous_complete, current_complete, fields):
    if previous_complete is not True or current_complete is not True:
        raise ValueError("Both snapshots must be confirmed complete")
    if (not isinstance(previous_scope, str) or not previous_scope.strip()
            or previous_scope != current_scope):
        raise ValueError("Snapshot scopes must match")
    if (not isinstance(fields, (list, tuple)) or not fields
            or any(not isinstance(f, str) or not f for f in fields)
            or len(fields) != len(set(fields))):
        raise ValueError("Choose distinct comparison fields")

    def index(rows):
        result = {}
        for row in rows:
            if not isinstance(row, dict):
                raise TypeError("Each row must be a dictionary")
            key = tuple(row.get(k) for k in
                        ("portal_id", "object_type", "record_id"))
            if any(not isinstance(v, str) or not v or v != v.strip()
                   for v in key):
                raise ValueError("Keys must be nonempty, unmodified strings")
            if key in result:
                raise ValueError("Duplicate scoped record key")
            if any(field not in row for field in fields):
                raise ValueError("A comparison field is missing")
            result[key] = tuple(row[field] for field in fields)
        return result

    old, new = index(previous), index(current)
    return {
        "insert_keys": sorted(new.keys() - old.keys()),
        "changed_keys": sorted(k for k in old.keys() & new.keys()
                               if old[k] != new[k]),
        "absent_keys_for_review": sorted(old.keys() - new.keys()),
    }


old = [
    {"portal_id": "p1", "object_type": "contacts", "record_id": "001",
     "owner": "A"},
    {"portal_id": "p1", "object_type": "contacts", "record_id": "002",
     "owner": "B"},
]
new = [
    {"portal_id": "p1", "object_type": "contacts", "record_id": "001",
     "owner": "C"},
    {"portal_id": "p1", "object_type": "contacts", "record_id": "003",
     "owner": "D"},
]
result = snapshot_diff(
    old, new, previous_scope="p1-contacts-active-v1",
    current_scope="p1-contacts-active-v1", previous_complete=True,
    current_complete=True, fields=("owner",),
)
assert result["changed_keys"] == [("p1", "contacts", "001")]
assert result["insert_keys"] == [("p1", "contacts", "003")]
assert result["absent_keys_for_review"] == [("p1", "contacts", "002")]
```

The two snapshots each contain two records, yet one owner changed, one record arrived and another disappeared from the selected population. A count-only comparison would miss all three changes.

A cleared field is also different from an omitted field. The helper compares an explicit `None` value but rejects a missing requested field. Your extraction contract must define how source nulls and empty strings are normalized. Do not convert an omitted property into permission to erase an existing value.

Preserve historical snapshots separately if the report needs prior states. Updating today's row cannot answer what its owner or stage was last month. The [late-arriving data guide](https://www.martechdevs.com/blog/late-arriving-marketing-data) covers corrections that must repair a historical report without duplicating the underlying record.

## How do you keep cell types and formulas from changing the data?

Store imported values in a controlled raw tab and apply presentation logic in a separate reporting layer. Keep identifiers as text, amounts with explicit currency and timestamps with an agreed timezone interpretation.

HubSpot's workflow action sends internal property values, and dates arrive as Unix milliseconds by default. Preserve those raw values before translating them into labels or display dates. An internal stage identifier and its display label serve different purposes. [Workflow value formats](https://knowledge.hubspot.com/workflows/add-data-to-google-sheets-with-workflows).

For custom Sheets API writes, choose the input interpretation deliberately. `RAW` stores supplied values without parsing; `USER_ENTERED` applies the spreadsheet's input interpretation, including conversions to numbers or dates. Use the route that preserves imported identifiers and source text, then format intentional numbers and dates separately. [Google Sheets value input options](https://developers.google.com/workspace/sheets/api/reference/rest/v4/ValueInputOption).

Do not let imported text become an unintended formula. Include values beginning with `=`, leading-zero identifiers and large numeric-looking IDs in the fixture. Check the stored value and its displayed value, because a sheet can display a shortened number after the underlying identity has already changed.

Keep manual notes in a keyed table that the loader does not overwrite. A report can look up notes by record ID; a comment typed beside row 42 can become attached to a different customer after a refresh sorts the raw data.

## What should you check when the integration fails?

Locate the failed boundary before retrying: source selection, workflow execution, row matching, destination write or report calculation. A blind replay can create more work if the original request succeeded but its response was lost.

| Symptom | First evidence to inspect | Useful correction |
| --- | --- | --- |
| Two rows for one contact | Duplicate keys, action history and concurrent writers | Repair the duplicate population, then test retry and matching behavior |
| Old owner or stage | Enrollment history and the last relevant source change | Repair trigger coverage or reconcile through a scheduled refresh |
| New records never appear | Unmatched-record policy and mapped output key | Apply the approved insert behavior and retest a new key |
| Missing records after refresh | Extraction completion, page coverage and filters | Restore the prior accepted version; investigate the incomplete run |
| Data exists but totals are wrong | Grain, associations, currency and repeated facts | Repair the reporting model rather than re-importing blindly |
| Custom writer receives quota errors | Request count, batch size and concurrent jobs | Reduce calls and use bounded retries with backoff |

Google publishes separate Sheets API read and write quotas of 300 requests per minute per project and 60 per minute per user per project. It recommends exponential backoff after quota errors and sets a 180-second processing limit for a request. These are API constraints; they are not a promise about an automation vendor's throughput. [Google Sheets API limits](https://developers.google.com/workspace/sheets/api/limits).

Batch API operations where appropriate. Google applies a request atomically, but several separate requests do not become a transaction merely because the same job issued them. Design publication and recovery around the actual sequence of writes. [Sheets API request behavior](https://developers.google.com/workspace/sheets/api/limits).

## When should the sheet stop being the main data pipeline?

Move the pipeline when the sheet can no longer meet the required reliability, history, access or recovery agreement with maintainable effort. There is no universal record count at which every spreadsheet becomes unsuitable.

A small dataset with strict client isolation can need a maintained data layer sooner than a larger internal lookup. Repeated cross-source joins, historical reconstruction, several competing writers and manual repair before every reporting meeting are stronger signals than the file size alone.

Measure the complete refresh duration, failures, unexplained missing keys and operating time. Use [data quality metrics](https://www.martechdevs.com/blog/data-quality-metrics) to keep those counts and denominators explicit in the run record. Do not choose a threshold from a demonstration that used fewer columns, formulas or source objects than production.

The decision below separates record-level writes from complete reporting refreshes. Both require duplicate and recovery checks before the report is released.

![The required sheet population and reporting deadline determine whether to build a complete candidate snapshot or a keyed record-change feed. Failed coverage, identity or retry checks hold publication. Accepted data remains in Sheets only if its history, access and recovery requirements can be met; otherwise the maintained path moves to a warehouse.](https://www.martechdevs.com/assets/blog/hubspot-google-sheets-integration.svg)

Diagram connections:

- Define the sheet population key and reporting deadline to Need a complete reconciled reporting snapshot?. 
- Need a complete reconciled reporting snapshot? to Extract all selected records into a candidate dataset: Yes. 
- Need a complete reconciled reporting snapshot? to Configure a keyed record-change feed: No. 
- Extract all selected records into a candidate dataset to Coverage identity and retry tests pass?. 
- Configure a keyed record-change feed to Coverage identity and retry tests pass?. 
- Coverage identity and retry tests pass? to Hold publication and repair the failed boundary: No. 
- Hold publication and repair the failed boundary to Define the sheet population key and reporting deadline. 
- Coverage identity and retry tests pass? to The sheet meets history access and recovery needs?: Yes. 
- The sheet meets history access and recovery needs? to Move the maintained data path to a warehouse: No. 
- The sheet meets history access and recovery needs? to Publish the accepted output with a named owner: Yes

Before handing over the integration:

## Checklist

- [ ] Direction, account, object, filters and row grain are explicit.
- [ ] Record IDs remain strings and match only inside their intended scope.
- [ ] The current raw dataset has no duplicate matching keys.
- [ ] New, changed, cleared and absent records have tested behavior.
- [ ] The refresh cannot publish an incomplete extract as a successful result.
- [ ] Analyst formulas and manual notes are protected from loader writes.
- [ ] A retry, credential interruption and changed sheet mapping have been tested.
- [ ] Monitoring, recovery and access ownership are documented.

If the sheet supports a disputed report, [get the $99 martech audit](https://www.martechdevs.com/pricing) with one incorrect record and its expected result. For a defined extraction or migration, [request a detailed reporting implementation scope](https://www.martechdevs.com/data-warehouse-cdp#offer).

## Frequently asked questions

### Can an email change create a second row for the same contact?

Yes, when the integration uses email as the lookup key, a changed address can look like a new unmatched record. Use the source record ID within the correct account and object scope, and test the email change as an ordinary field update. Handle merged contacts through an explicit surviving-record mapping rather than assuming every identifier remains independent forever.

### Should analysts sort the tab that receives HubSpot updates?

Prefer sorting a report view or a separate analysis tab while the loader owns the raw tab. Keyed matching should not depend on row order, but manual edits, formulas and concurrent writes can still create inconsistent results. Test the selected integration's behavior under sorting, and keep analyst notes keyed by record identity rather than positioned beside a row number.

### Does a blank cell mean the CRM property was cleared?

No, a blank destination cell can represent a cleared source value, an omitted property, a failed mapping or a field the integration cannot access. Preserve those distinctions in the extraction contract. Test an explicit clear separately from a missing property, and inspect the source response before letting an empty destination value overwrite a previously known field.

### Can one sheet mix records from several HubSpot accounts?

It can, provided the dataset retains each account and object scope and the access policy permits that combination. Use a composite key rather than the record ID alone, and keep independent extraction completion records for each source. If one account fails, the report should expose that incomplete scope rather than presenting the successful accounts as a complete combined population.

### What happens when the Google integration owner leaves?

The outcome depends on the credentials, file ownership and permissions used by the integration. Record those dependencies before handover and test an approved replacement identity against the same files and workflow actions. Keep a runbook for reconnecting, validating access and reconciling missed updates. A spreadsheet remaining visible to analysts does not establish that its automated writer still has working access.

## Sources

1. [HubSpot Google Sheets workflow app](https://ecosystem.hubspot.com/marketplace/listing/google-sheets-228553)

2. [HubSpot Assistant for Google Sheets](https://www.hubspot.com/free-business-tools/hubspot-for-google-sheets)

3. [HubSpot record exports](https://knowledge.hubspot.com/import-and-export/export-records)

4. [HubSpot Contacts API](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide)

5. [HubSpot workflow setup and limits](https://knowledge.hubspot.com/workflows/add-data-to-google-sheets-with-workflows)

6. [HubSpot update-action explanation](https://community.hubspot.com/t/now-live-update-existing-rows-in-google-sheets-from-workflows/50512)

7. [HubSpot workflow re-enrollment](https://knowledge.hubspot.com/workflows/add-re-enrollment-triggers-to-a-workflow)

8. [HubSpot authentication change](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset)

9. [Google Sheets value input options](https://developers.google.com/workspace/sheets/api/reference/rest/v4/ValueInputOption)

10. [Google Sheets API limits](https://developers.google.com/workspace/sheets/api/limits)
