moving from Proton Pass to 1Password
published : 2026.10.31
Your passwords are unencrypted during this process
- The export and the CSV files hold every password in plain text. Keep them in a folder that isn't synced to the cloud, and pause backup software while you work. 1Password's own guide recommends this too.
- The scripts use only Python's standard library and make no network connections. Read them before you run them. Each one is short.
- When the import is done and checked, delete the export, the deduplicated zip and the CSV files, then empty the Trash.
Steps
Export from Proton Pass
In Proton Pass, open Settings → Export and choose the unencrypted JSON format. You get a zip file containing
Proton Pass/data.json. JSON keeps every field, including passkeys, identities, trash state and multiple URLs per login, so the script can decide where each one goes.Remove duplicates (optional)
Did an earlier import into Proton run twice? Then most items exist two or more times.
dedupe_proton.pykeeps one copy of each. Two items only count as duplicates when everything matches: name, note, username, email, password, URLs, TOTP, passkeys and custom fields. The internal item ID is ignored, because it changes on every import. Logins with the same name but a different password or account are kept.$ python3 -I dedupe_proton.py "Proton Pass_export.zip" deduplicated.zipPersonal: 1202 -> 718 itemsThe output above is from a real run, where 1,202 items came down to 718.
Convert to CSV
proton_to_1password.pyreads the zip and writes CSV files into the folder you name. Different item types go into separate files, because 1Password labels columns once for each file.$ python3 -I proton_to_1password.py deduplicated.zip .1password_logins.csv: 697 rows1password_logins_trashed.csv: 6 rows1password_secure_notes.csv: 15 rowsA file is only written if there is something to put in it. In this run there were no credit cards, so
1password_credit_cards.csvwasn't created.Import into 1Password
- Sign in at 1Password.com, select your name, then choose Import data → CSV File.
- Import one file at a time. Set the first row, which holds the column names, to Ignore Row.
- Label each column with the matching 1Password field. If the One-time password column is empty in every row, set it to Ignore Column.
- The trashed logins file is there in case you want those items back. Skipping it loses nothing you were using.
1Password describes the format on its CSV import support page.
Check, then clean up
Spot-check a few logins in 1Password, especially ones with notes or several websites. Then delete every export and CSV file and empty the Trash.
Where each field ends up
1Password's CSV import has fixed columns for logins, credit cards and secure notes. Anything that has no column of its own is added to the item's notes as labelled lines, so nothing is silently dropped.
| Proton Pass | 1Password | Details |
|---|---|---|
| Login name | Title | Unchanged. |
| URLs | WebsiteNotes | The first URL becomes the website. Any others are listed under "Other websites:" in notes. |
| Username, email | Username | Uses the username, or the email if there is no username. If a login has both, the email goes in notes. |
| Password | Password | Unchanged, including commas and quotes. Every field is quoted in the CSV. |
| TOTP | One-time password | The otpauth:// URI is passed through as-is. |
| Note, custom fields | Notes | The note first, then each custom field as "Name: value". |
| Passkeys | Not transferable | CSV can't carry passkeys. The login still imports, and its notes name the site so you can create a new passkey in 1Password. |
| Trashed logins | Separate file | Written to 1password_logins_trashed.csv so you can choose whether to import them. |
| Credit cards | Credit card | Number, expiry (YYYY-MM converted to MM/YY), CVV and cardholder name. The PIN goes in notes. |
| Identities | Secure note | CSV import has no identity type, so the details become labelled lines, such as "City: …". You may want to re-create these as Identity items later. |
| Notes | Secure note | Title and text. |
| Aliases, other types | Secure note | Records the alias address, or the item's fields for any other type. Email aliases keep working in Proton. Only the record of them moves. |
The scripts
Save each script as a .py file and run it with Python 3, which macOS installs with the command line developer tools. The -I flag runs Python in isolated mode, so it ignores stray modules in the current folder. Both scripts expect the zip layout Proton Pass produces, with the data at Proton Pass/data.json.
"""Remove exact duplicate items from a Proton Pass JSON export (zip).Usage: python3 dedupe_proton.py "Proton Pass_export.zip" deduplicated.zipTwo items count as duplicates only when everything Proton stores about themmatches (name, note, username, email, password, URLs, TOTP, passkeys, customfields) apart from the internal item ID, which changes on every import.When copies differ only in trash state, the active copy is kept."""import collectionsimport jsonimport sysimport zipfilesrc, out = sys.argv[1], sys.argv[2]data = json.loads(zipfile.ZipFile(src).read("Proton Pass/data.json"))def fingerprint(item): d = json.loads(json.dumps(item["data"])) d["metadata"].pop("itemUuid", None) return json.dumps(d, sort_keys=True)for vault in data["vaults"].values(): groups = collections.OrderedDict() for item in vault["items"]: groups.setdefault(fingerprint(item), []).append(item) # state 1 is active, 2 is trashed; prefer active, then most recently modified kept = [min(g, key=lambda i: (i["state"] != 1, -i["modifyTime"])) for g in groups.values()] print(f"{vault['name']}: {len(vault['items'])} -> {len(kept)} items") vault["items"] = keptwith zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("Proton Pass/data.json", json.dumps(data, ensure_ascii=False, indent=2))"""Convert a Proton Pass JSON export (zip) into CSV files for 1Password's generic CSV import.Usage: python3 proton_to_1password.py "Proton Pass_export.zip" output_folderWrites up to four files: active logins, trashed logins, credit cards, andsecure notes (notes, identities, aliases and any other item types)."""import csvimport jsonimport osimport sysimport zipfilesrc, outdir = sys.argv[1], sys.argv[2]data = json.loads(zipfile.ZipFile(src).read("Proton Pass/data.json"))IDENTITY_LABELS = { "fullName": "Full name", "firstName": "First name", "middleName": "Middle name", "lastName": "Last name", "birthdate": "Birthdate", "gender": "Gender", "email": "Email", "phoneNumber": "Phone", "secondPhoneNumber": "Second phone", "organization": "Organization", "streetAddress": "Street address", "floor": "Floor", "city": "City", "county": "County", "stateOrProvince": "State/Province", "zipOrPostalCode": "ZIP/Postal code", "countryOrRegion": "Country/Region", "company": "Company", "jobTitle": "Job title", "workEmail": "Work email", "workPhoneNumber": "Work phone", "website": "Website", "personalWebsite": "Personal website", "socialSecurityNumber": "SSN", "passportNumber": "Passport number", "licenseNumber": "License number", "xHandle": "X handle", "facebook": "Facebook", "instagram": "Instagram", "linkedin": "LinkedIn", "reddit": "Reddit", "yahoo": "Yahoo",}def custom_field_lines(fields): return [f"{f['fieldName']}: {f['data'].get('content', '')}" for f in fields]def card_expiry(value): # Proton stores YYYY-MM; 1Password's example uses MM/YY if len(value) == 7 and value[4] == "-": return f"{value[5:]}/{value[2:4]}" return valuelogins, trashed, cards, notes = [], [], [], []for vault in data["vaults"].values(): for item in vault["items"]: d = item["data"] meta, content, kind = d["metadata"], d["content"], d["type"] title = meta["name"] note_parts = [meta["note"]] if meta["note"] else [] extra = custom_field_lines(d.get("extraFields", [])) if kind == "login": username = content["itemUsername"] or content["itemEmail"] if content["itemUsername"] and content["itemEmail"]: extra.append(f"Email: {content['itemEmail']}") urls = content["urls"] if len(urls) > 1: extra.append("Other websites: " + ", ".join(urls[1:])) for pk in content.get("passkeys", []): extra.append(f"Had a passkey in Proton Pass for {pk.get('rpId') or pk.get('domain')} " f"(user {pk.get('userName')}); passkeys can't be imported, re-create it.") if extra: note_parts.append("\n".join(extra)) row = [title, urls[0] if urls else "", username, content["password"], content.get("totpUri", ""), "\n\n".join(note_parts)] (trashed if item["state"] == 2 else logins).append(row) continue if kind == "creditCard": if content.get("pin"): extra.append(f"PIN: {content['pin']}") if extra: note_parts.append("\n".join(extra)) cards.append([title, content.get("number", ""), card_expiry(content.get("expirationDate", "")), content.get("verificationNumber", ""), content.get("cardholderName", ""), "\n\n".join(note_parts)]) continue # Everything else becomes a secure note lines = [] if kind == "identity": lines = [f"{label}: {content[k]}" for k, label in IDENTITY_LABELS.items() if content.get(k)] for section in ("extraPersonalDetails", "extraAddressDetails", "extraContactDetails", "extraWorkDetails"): lines += custom_field_lines(content.get(section, [])) for section in content.get("extraSections", []): lines += ["", f"[{section['sectionName']}]"] + custom_field_lines(section["sectionFields"]) elif kind == "alias": lines = [f"Alias address: {item.get('aliasEmail') or ''}"] elif kind != "note": lines = [f"Proton Pass item type: {kind}"] lines += [f"{k}: {v}" for k, v in content.items() if isinstance(v, (str, int)) and v != ""] if lines: note_parts.insert(0, "\n".join(lines)) if extra: note_parts.append("\n".join(extra)) notes.append([title, "\n\n".join(note_parts)])def write(name, header, rows): if not rows: return path = os.path.join(outdir, name) with open(path, "w", newline="", encoding="utf-8") as f: w = csv.writer(f, quoting=csv.QUOTE_ALL) w.writerow(header) w.writerows(rows) print(f"{name}: {len(rows)} rows")login_header = ["Title", "Website", "Username", "Password", "One-time password", "Notes"]write("1password_logins.csv", login_header, logins)write("1password_logins_trashed.csv", login_header, trashed)write("1password_credit_cards.csv", ["Title", "Card number", "Expiry", "CVV", "Cardholder name", "Notes"], cards)write("1password_secure_notes.csv", ["Title", "Notes"], notes)