Data Import — Bring Your History From Another App
A one-time CSV import for a user switching to Objectuve: two files — goals.csv and habit_completions.csv — become real goals and check-ins, so switching doesn't mean starting from zero.
Availability
Import is merged to master behind the data_import_enabled feature flag, currently at 0% rollout — it is built, but not reachable by any real user yet. This page documents what ships once the flag ramps, not something you can use today.
Native file-picker support on the iOS/Android WebView is unverified (OBJ-3874) and is a gate on moving the flag past 0%.
How it works
Reached from Settings → Privacy, above the "Export my data" card, at /settings/import. Three steps:
- Pick your files — select
goals.csvand/orhabit_completions.csv. Either file alone is accepted; you don't need both. - Check what we found — a preview shows how many goals and check-ins were parsed, any row-level errors or warnings, and flags a goal whose name collides with one you already have. For each collision you choose skip (default — keep your existing goal, don't import the duplicate) or Add anyway (import it as a separate goal).
- Bring it in — committing runs asynchronously; the page polls for progress every 2 seconds while the import is in flight, then shows a summary of what was created, skipped, and failed.
The CSV contract
Column names, requiredness, and validation are the single responsibility of ImportExport::CsvSchema (rails_api/app/services/import_export/csv_schema.rb) — this table matches it exactly.
goals.csv
| Column | Required? | Notes |
|---|---|---|
external_id | Required | Your own identifier for the goal — used to link its rows in habit_completions.csv via goal_external_id. Never stored or shown; it only exists to join the two files during import. |
name | Required | |
description | Optional | |
life_area | Optional | An unrecognized value is a warning, not an error — the goal still imports, with life_area falling back to other. |
recurrence_type | Optional | Must be one of daily, weekly, custom_days, interval — an unrecognized value is an error and the row is rejected. |
recurrence_days | Optional | A single |-delimited string, e.g. mon|wed|fri — not a raw array dump. |
recurrence_interval | Optional | |
duration_minutes | Optional | |
target_amount | Optional | |
unit | Optional | |
target_date | Optional |
habit_completions.csv
| Column | Required? | Notes |
|---|---|---|
goal_external_id | Required | Must match an external_id in goals.csv. |
completed_date | Required | ISO-8601 YYYY-MM-DD. Rejected as an error if it isn't valid ISO-8601, or if it's in the future. |
note | Optional |
What doesn't come across, on purpose
- No streak column, and there isn't one to fill in. Every goal you import starts its streak from zero and rebuilds it from the
habit_completionsyou bring in — the same history, recounted the way Objectuve counts it, not a number copied from another app's rules. This is deliberate, not a gap: a streak is derived, never imported. - No visibility column. Every imported goal is created private. A CSV has no business minting a visibility state on your behalf — if you want a goal public or allies-only, that's a change you make afterward, the same as any other goal.
Caps and limits
- 2 MB per file, 5,000 rows per file. A file over either limit is rejected outright, before any rows are parsed.
- 5 import attempts per minute, per IP address. Starting an import (
beginDataImport) beyond that is rate-limited; the picker distinguishes this from a bad file and tells you how long to wait.
Partial failure
A row that fails during commit — a goal that hits an unexpected error, a duplicate completion, a completion whose goal never got created — is recorded and counted, but it does not roll back the rest of the import. The import still finishes in a committed state with whatever succeeded intact; the summary screen breaks down what was created, skipped, and failed. A failed status is reserved for a catastrophic, unexpected failure of the whole run, not for individual bad rows.
Export → import round-trip
Because goals.csv and habit_completions.csv in your data export use this exact same column contract, exporting your data from Objectuve and importing it straight back in is a supported path — it's also how the importer is integration-tested. This is useful for moving your history between two Objectuve accounts, or just confirming what a full round-trip preserves.
Switching from another app?
If you're coming from Habitica, Strides, Apple Health, or Google Fit, see Import Provider Evaluation for what each provider can and can't hand you in this format today. If you want an ongoing, forward-only sync instead of a one-time historical migration, see Connected Apps — the two solve different problems and aren't interchangeable.
Technical details
- CSV contract:
ImportExport::CsvSchema(rails_api/app/services/import_export/csv_schema.rb) — the only place these column names are declared.Gdpr::ExportBuilderreads it to shape export output; the importer reads it to validate incoming rows. - Parse/preview:
ImportExport::ParseCsv(rails_api/app/interactions/import_export/parse_csv.rb) — enforces the 2 MB / 5,000-row caps, tracks physical CSV line numbers for error messages, and flags name collisions against your existing goals. Creates a stagedDataImportrecord; nothing is written toGoal/HabitCompletionyet. - Commit:
ImportExport::Commit(rails_api/app/interactions/import_export/commit.rb) flips theDataImporttocommittingand enqueuesImportExport::CommitJob(rails_api/app/jobs/import_export/commit_job.rb), which creates goals one at a time throughGoalTracking::AddGoal(each goal has real side effects — starter freeze, milestone handling — that can't be bulked), bulk-inserts habit completions in oneinsert_all, then recomputes streaks from the imported completions. - GraphQL surface:
beginDataImport(parses and stages),commitDataImport(commits a staged import, withskipGoalExternalIdsfor collisions to skip), and thedataImportStatusquery (polled during commit). Backend Ruby namespace isImportExport::*; the GraphQL layer keeps theDataImportname —mutations/data_import/{begin_import,commit_import}.rb,resolvers/data_import_queries.rb,types/data_import_type.rb. - Frontend:
ionic_frontend/src/views/DataImportPage.vue(/settings/import, route-guarded bydata_import_enabled) drives a picker → preview → progress → summary state machine viaDataImportPicker/DataImportPreview/DataImportProgress/DataImportSummary(ionic_frontend/src/components/dataImport/). Progress is a bounded 2-second poll whilecommitting, not a subscription. - Rate limiting:
data-import-ipthrottle inrails_api/config/initializers/rack_attack.rb— 5beginDataImportcalls per minute per IP, returned as an HTTP 429 with aretry_afterthe picker surfaces directly.
See Also
- Data Export — the export side of this same CSV contract, and the round-trip path
- Connected Apps — ongoing, forward-only sync (Strava, Chess.com) vs. this one-time historical migration
- Import Provider Evaluation — why Habitica/Strides map to this importer and Apple Health/Google Fit don't
- Roadmap: item 45, Import from Other Apps
Last updated: 2026-09-15 · Version: v4.64 (Phases 1, 3, 5, merged to master)