$npx -y skills add Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-errors-databaseUse when handling database errors in Frappe/ERPNext. Covers DuplicateEntryError, LinkValidationError, MandatoryError, TimestampMismatchError, CharacterLengthExceededError, InReadOnlyMode, QueryTimeoutError, SQL injection errors, frappe.db.sql parameter format (% vs %s), get_value
| 1 | # Frappe Database Error Diagnosis & Resolution |
| 2 | |
| 3 | Cross-ref: `frappe-core-database` (API syntax), `frappe-errors-controllers` (controller errors). |
| 4 | |
| 5 | --- |
| 6 | |
| 7 | ## Error-to-Fix Mapping Table |
| 8 | |
| 9 | | Error / Exception | HTTP | Cause | Fix | |
| 10 | |-------------------|------|-------|-----| |
| 11 | | `DuplicateEntryError` | 409 | Unique constraint violation on insert/rename | Check existence first OR catch and return existing | |
| 12 | | `DoesNotExistError` | 404 | `get_doc()` on missing record | Use `frappe.db.exists()` first OR catch exception | |
| 13 | | `LinkValidationError` | 417 | Link field points to non-existent record | Validate link target exists before save | |
| 14 | | `LinkExistsError` | N/A | Delete blocked by linked documents | Show linked docs to user; use `force=True` carefully | |
| 15 | | `MandatoryError` | 417 | Required field is empty on save | Set all mandatory fields before insert/save | |
| 16 | | `TimestampMismatchError` | N/A | Concurrent edit detected (`modified` changed) | Reload doc and retry, or inform user to refresh | |
| 17 | | `CharacterLengthExceededError` | 417 | String exceeds field maxlength / DB column size | Truncate input or increase field length | |
| 18 | | `DataTooLongException` | 417 | Value exceeds DB column storage capacity | Same as CharacterLengthExceededError | |
| 19 | | `InReadOnlyMode` | 503 | Write attempted during read-only mode | Check `frappe.flags.in_import` or site config | |
| 20 | | `QueryTimeoutError` | N/A | Query exceeded time limit [v15+] | Add indexes, reduce result set, paginate | |
| 21 | | `QueryDeadlockError` | N/A | Two transactions waiting on each other | Retry with backoff; reduce transaction scope | |
| 22 | | `TooManyWritesError` | N/A | Excessive writes in single request | Batch operations; use background jobs | |
| 23 | | `InternalError` (gone away) | N/A | MariaDB connection dropped | Reconnect with `frappe.db.connect()` | |
| 24 | | `InternalError` (too many) | N/A | Connection pool exhausted | Check `max_connections`; close idle connections | |
| 25 | | `ValidationError` | 417 | General validation failure in save | Read error message; fix field values | |
| 26 | | SQL syntax error | N/A | Wrong `frappe.db.sql()` parameter format | Use `%(name)s` with dict, NOT `%s` with tuple | |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## Exception Hierarchy |
| 31 | |
| 32 | ``` |
| 33 | Exception |
| 34 | ├── frappe.ValidationError (HTTP 417) |
| 35 | │ ├── frappe.MandatoryError |
| 36 | │ ├── frappe.LinkValidationError |
| 37 | │ ├── frappe.CharacterLengthExceededError |
| 38 | │ ├── frappe.DataTooLongException |
| 39 | │ ├── frappe.UniqueValidationError |
| 40 | │ ├── frappe.UpdateAfterSubmitError |
| 41 | │ └── frappe.DataError |
| 42 | ├── frappe.DoesNotExistError (HTTP 404) |
| 43 | ├── frappe.DuplicateEntryError (HTTP 409) ← inherits NameError |
| 44 | ├── frappe.TimestampMismatchError |
| 45 | ├── frappe.LinkExistsError |
| 46 | ├── frappe.QueryTimeoutError |
| 47 | ├── frappe.QueryDeadlockError |
| 48 | ├── frappe.TooManyWritesError |
| 49 | ├── frappe.InReadOnlyMode (HTTP 503) |
| 50 | └── frappe.db.InternalError ← MariaDB/Postgres driver error |
| 51 | ``` |
| 52 | |
| 53 | --- |
| 54 | |
| 55 | ## frappe.db.sql() Parameter Format |
| 56 | |
| 57 | ```python |
| 58 | # ❌ WRONG — %s with positional tuple (works but fragile) |
| 59 | frappe.db.sql("SELECT * FROM `tabItem` WHERE name = %s", ("ITEM-001",)) |
| 60 | |
| 61 | # ❌ WRONG — f-string or .format() — SQL INJECTION! |
| 62 | frappe.db.sql(f"SELECT * FROM `tabItem` WHERE name = '{item_name}'") |
| 63 | frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '{}'".format(item_name)) |
| 64 | |
| 65 | # ❌ WRONG — bare % operator |
| 66 | frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '%s'" % item_name) |
| 67 | |
| 68 | # ✅ CORRECT — named parameters with dict (ALWAYS use this) |
| 69 | frappe.db.sql( |
| 70 | "SELECT * FROM `tabItem` WHERE name = %(name)s AND warehouse = %(wh)s", |
| 71 | {"name": item_name, "wh": warehouse}, |
| 72 | as_dict=True |
| 73 | ) |
| 74 | |
| 75 | # ✅ CORRECT — frappe.qb (query builder, no injection risk) |
| 76 | Item = frappe.qb.DocType("Item") |
| 77 | result = ( |
| 78 | frappe.qb.from_(Item) |
| 79 | .select(Item.name, Item.item_name) |
| 80 | .where(Item.warehouse == warehouse) |
| 81 | .run(as_dict=True) |
| 82 | ) |
| 83 | ``` |
| 84 | |
| 85 | **Rule**: ALWAYS use `%(name)s` with a dict parameter. NEVER use string formatting for SQL values. |
| 86 | |
| 87 | --- |
| 88 | |
| 89 | ## get_value Returns None: Not an Exception |
| 90 | |
| 91 | ```python |
| 92 | # ❌ DANGEROUS — get_value returns None, not raises |
| 93 | credit = frappe.db.get_value("Customer", "CUST-001", "credit_limit") |
| 94 | if credit > 1000: # TypeError: '>' not supported between NoneType and int |
| 95 | pass |
| 96 | |
| 97 | # ✅ CORRECT — handle None explicitly |
| 98 | credit = fra |