So many Email addresses
(mostly answered by Claude.AI)
At the UI level, there is no direct Email ID field on the Customer DocType itself.
It is redundant by design, and it's a known architectural quirk in ERPNext. Here's why both exist and what each one actually does:
The Two Fields Explained
1. Contact.is_primary_contact (on the Contact DocType)
- This is a flag on the Contact record itself
- It means: "among all contacts linked to this customer, this one is the primary"
- It is set when you tick "Is Primary Contact" on the Contact form
- ERPNext uses this to auto-populate contact fields in transactions (Sales Order, Sales Invoice, etc.)
- Problem: Nothing enforces uniqueness — two contacts for the same customer can both have
is_primary_contact = 1, which is a data integrity gap
2. Customer.customer_primary_contact (on the Customer DocType)
- This is a direct link field (a
Linkfield pointing toContact) stored on the Customer record itself - It gives you a single, authoritative lookup: "what is the primary contact for this customer?" without having to query the Contact table at all
- It is updated automatically by ERPNext when a contact is marked as primary
- Much faster for API queries — one GET on the Customer record returns the primary contact name immediately
Why Both Exist — The Real Reason
Customer ──────────────────────────────────────────────────────────┐
customer_primary_contact = "Jane Smith" ← fast denormalised │
lookup │
│
Contact (Jane Smith) │
links → [{ link_doctype: "Customer", link_name: "CUST-00001" }] │
is_primary_contact = 1 ← flag used during transaction │
auto-population & filtering ───────────┘
It is essentially denormalisation for performance — a classic database trade-off:
| Concern | is_primary_contact
|
customer_primary_contact
|
|---|---|---|
| Where stored | Contact record | Customer record |
| Purpose | Flag for filtering/transactions | Fast direct lookup |
| Query cost | Requires JOIN / filter on Contact | Single field on Customer fetch |
| Risk | Multiple contacts can be flagged 1
|
Can go stale if not synced |
| Updated by | User manually / Contact save hook | ERPNext hook on Contact save |
The Sync Risk
The real danger is they can fall out of sync:
- If you update
is_primary_contactvia a direct API PATCH on the Contact but ERPNext's server-side hook doesn't fire correctly,customer_primary_contacton the Customer won't update - If you delete a Contact that is the primary,
customer_primary_contactmay hold a dead reference until another contact is promoted
Practical Advice
| Use case | Use this field |
|---|---|
| Fetch primary contact quickly in one API call | Customer.customer_primary_contact
|
| Filter all contacts to find which is primary | Contact.is_primary_contact = 1
|
| Trust for data integrity | Neither alone — verify both match |
| Setting a new primary contact | Always do it via the ERPNext UI or server-side method so both fields stay in sync |
It's a deliberate denormalisation that trades data integrity risk for query performance, and it's something ERPNext developers themselves have debated in the Frappe GitHub issues over the years.
ERPNext's Address DocType also has its own email_id field, completely separate from the Contact email chain.
The Full Picture
Customer (CUST-00001 - "Acme Corp")
│
├──► customer_primary_contact ──► Contact "Jane Smith"
│ │ └── email_ids[]
│ │ └── jane@acme.com ← person email
│
├──► customer_primary_address ──► Address "Acme Corp-Billing"
│ └── email_id: accounts@acme.com ← address email
│
└──► Dynamic Links (multiple)
│
├── Address "Acme Corp-Billing"
│ └── email_id: accounts@acme.com
│
├── Address "Acme Corp-Shipping"
│ └── email_id: warehouse@acme.com
│
└── Address "Acme Corp-Head Office"
└── email_id: info@acme.com
Three Completely Separate Email Pools
| Source | Field | Typical Use | Example |
|---|---|---|---|
Contact.email_ids[]
|
email_id
|
Person-to-person communication | jane@acme.com
|
Address.email_id
|
email_id
|
Location/department email | accounts@acme.com
|
Customer
|
(none) | — | — |
Each pool is entirely independent — no sync, no relationship between them whatsoever.
Why Address Has Its Own Email
The Address DocType is designed to represent a physical or operational location, and in B2B that location often has its own functional email:
Acme Corp - Billing Address → accounts@acme.com (AP/AR team)
Acme Corp - Shipping Address → warehouse@acme.com (logistics team)
Acme Corp - Head Office → info@acme.com (general enquiries)
This makes sense in the real world — when you send an invoice, you email the billing address email, not Jane's personal email.
How ERPNext Decides Which Email to Use
This is where it gets messy. Depending on the transaction type, ERPNext picks email from different sources:
| Transaction | Where ERPNext looks for email |
|---|---|
| Sales Invoice (send via email) | customer_primary_contact → email_id
|
| Print & Email from Address | Address.email_id
|
| Dunning / Payment Reminder | customer_primary_contact → email_id
|
| Shipping Notification | Address.email_id (shipping address)
|
| Portal / User login | Contact.email_ids[]
|
There is no single unified "customer email" — it depends entirely on the context and which button you press.
Summary of the Design Problem
| Issue | Impact |
|---|---|
No single Customer.email field
|
Must chase 2+ DocTypes to find any email |
| Address email ≠ Contact email | Easy to send invoices to the wrong place |
| No enforced "use this email for invoices" rule | Relies on user discipline |
| Multiple addresses, each with own email | No clear hierarchy without is_primary_address logic
|
| ERPNext picks email source based on transaction | Inconsistent and surprising behaviour |
It is a flexible but fragile design — powerful when set up correctly, but very easy to end up with missing or stale email data across the system.