skills/source/core/frappe-core-database/SKILL.md
Use when performing database operations in ERPNext/Frappe v14-v16. Covers frappe.db methods, ORM patterns (frappe.get_doc, frappe.get_list), raw SQL, caching patterns, and performance optimization. Prevents common mistakes with database transactions and query building. Keywords: frappe.db, frappe.get_doc, database query, SQL, ORM, caching, database performance, query returns nothing, slow database, how to fetch data, get document by name, frappe.get_list empty.
npx skillsauth add OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package frappe-core-databaseInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
| Action | Method | Permissions |
|--------|--------|-------------|
| Get document | frappe.get_doc(doctype, name) | Yes |
| Cached document | frappe.get_cached_doc(doctype, name) | No |
| New document | frappe.new_doc(doctype) | — |
| Insert | doc.insert() | Yes |
| Save | doc.save() | Yes |
| Delete document | frappe.delete_doc(doctype, name) | Yes |
| List (with perms) | frappe.db.get_list(doctype, ...) | Yes |
| List (no perms) | frappe.get_all(doctype, ...) | No |
| Single field | frappe.db.get_value(doctype, name, field) | No |
| Single DocType | frappe.db.get_single_value(doctype, field) | No |
| Cached value | frappe.db.get_value(..., cache=True) | No |
| Direct update | frappe.db.set_value(doctype, name, field, val) | No |
| Direct update | doc.db_set(field, value) | No |
| Exists check | frappe.db.exists(doctype, name) | No |
| Count | frappe.db.count(doctype, filters) | No |
| Delete rows | frappe.db.delete(doctype, filters) | No |
| Raw SQL | frappe.db.sql(query, values, as_dict) | No |
| Query Builder | frappe.qb.from_(doctype).select(...) | No |
"Permissions" = Yes means user permission filters are applied automatically.
What do you need?
│
├─ Create / Update / Delete a document?
│ ├─ With validations + hooks → frappe.get_doc() + .insert()/.save()/.delete()
│ └─ Direct DB (no hooks) → frappe.db.set_value() or doc.db_set()
│
├─ Read a single document?
│ ├─ Need full object with methods → frappe.get_doc()
│ ├─ Read-only, rarely changes → frappe.get_cached_doc()
│ └─ Only need 1-2 fields → frappe.db.get_value()
│
├─ List of documents?
│ ├─ Respect user permissions → frappe.db.get_list()
│ └─ System/admin context → frappe.get_all()
│
├─ Single DocType value?
│ └─ frappe.db.get_single_value('Settings', 'field')
│
├─ Check existence?
│ └─ frappe.db.exists() — NEVER use get_doc in try/except
│
├─ Complex query (JOINs, aggregates)?
│ ├─ Cross-DB compatible → frappe.qb (Query Builder)
│ └─ DB-specific SQL → frappe.db.sql() with parameters
│
└─ DB-specific logic?
└─ frappe.db.multisql({'mariadb': q1, 'postgres': q2})
RULE: ALWAYS use the highest abstraction level: ORM > Database API > Query Builder > Raw SQL.
doc = frappe.get_doc('Sales Invoice', 'SINV-00001')
# Single DocType (no name needed)
settings = frappe.get_doc('System Settings')
# Cached (read-only, for rarely-changing docs)
company = frappe.get_cached_doc('Company', 'My Company')
# Last created
last_task = frappe.get_last_doc('Task', filters={'status': 'Open'})
doc = frappe.get_doc({
'doctype': 'Task',
'subject': 'Review report',
'status': 'Open'
})
doc.insert()
# Alternative
doc = frappe.new_doc('Task')
doc.subject = 'Review report'
doc.insert()
# Via ORM — triggers validate, on_update, etc.
doc = frappe.get_doc('Task', 'TASK-001')
doc.status = 'Completed'
doc.save()
# Direct DB — SKIPS all validations and hooks
frappe.db.set_value('Task', 'TASK-001', 'status', 'Completed')
# Direct DB on loaded doc
doc.db_set('status', 'Completed')
doc.db_set('status', 'Completed', update_modified=False)
doc.db_set({'status': 'Completed', 'priority': 'High'})
frappe.delete_doc('Task', 'TASK-001')
# Also removes linked Communications, Comments, etc.
doc.insert(
ignore_permissions=True, # Bypass permission check
ignore_links=True, # Skip link validation
ignore_if_duplicate=True, # No error on duplicate
ignore_mandatory=True # Skip required field check
)
RULE: NEVER use multiple ignore flags together unless you have a documented reason. Each flag you add weakens data integrity.
# Single field → scalar
status = frappe.db.get_value('Task', 'TASK-001', 'status')
# Multiple fields → tuple
subject, status = frappe.db.get_value('Task', 'TASK-001', ['subject', 'status'])
# As dict
data = frappe.db.get_value('Task', 'TASK-001', ['subject', 'status'], as_dict=True)
# With filters instead of name
status = frappe.db.get_value('Task', {'project': 'PROJ-001'}, 'status')
# Cached (for values that rarely change)
country = frappe.db.get_value('Company', 'MyCompany', 'country', cache=True)
timezone = frappe.db.get_single_value('System Settings', 'time_zone')
# get_list — applies user permissions
tasks = frappe.db.get_list('Task',
filters={'status': 'Open'},
fields=['name', 'subject', 'assigned_to'],
order_by='creation desc',
start=0,
page_length=50
)
# get_all — NO permission check (same API, different default)
all_tasks = frappe.get_all('Task', filters={'status': 'Open'})
# pluck — returns flat list of single field
names = frappe.get_all('Task', filters={'status': 'Open'}, pluck='name')
# Returns: ['TASK-001', 'TASK-002', ...]
exists = frappe.db.exists('User', '[email protected]')
exists = frappe.db.exists('User', {'email': '[email protected]'})
total = frappe.db.count('Task')
open_count = frappe.db.count('Task', {'status': 'Open'})
{'status': 'Open'} # =
{'status': ['!=', 'Cancelled']} # !=
{'amount': ['>', 1000]} # >
{'amount': ['>=', 1000]} # >=
{'status': ['in', ['Open', 'Working']]} # IN
{'status': ['not in', ['Cancelled', 'Closed']]} # NOT IN
{'date': ['between', ['2024-01-01', '2024-12-31']]} # BETWEEN
{'subject': ['like', '%urgent%']} # LIKE
{'description': ['is', 'set']} # IS NOT NULL
{'description': ['is', 'not set']} # IS NULL
# AND — all conditions in one dict
filters = {'status': 'Open', 'priority': 'High'}
# AND — list format (allows duplicate fields)
filters = [['status', '=', 'Open'], ['priority', '=', 'High']]
# OR — separate parameter
or_filters = {'priority': 'Urgent', 'status': 'Overdue'}
# Single field
frappe.db.set_value('Task', 'TASK-001', 'status', 'Closed')
# Multiple fields
frappe.db.set_value('Task', 'TASK-001', {'status': 'Closed', 'priority': 'Low'})
# Without updating modified timestamp
frappe.db.set_value('Task', 'TASK-001', 'status', 'Closed', update_modified=False)
# Delete with filters (DML — can be rolled back)
frappe.db.delete('Error Log', {'creation': ['<', '2024-01-01']})
# Truncate (DDL — CANNOT be rolled back)
frappe.db.truncate('Error Log')
frappe.db.bulk_update('Task', {
'TASK-001': {'status': 'Closed'},
'TASK-002': {'status': 'Closed'}
}, chunk_size=100)
# ✅ CORRECT — parameterized query
results = frappe.db.sql("""
SELECT name, subject FROM `tabTask`
WHERE status = %(status)s AND owner = %(owner)s
""", {'status': 'Open', 'owner': frappe.session.user}, as_dict=True)
CRITICAL: NEVER use f-strings, % formatting, or string concatenation in SQL. See SQL Injection Prevention.
frappe.db.sql(query) # Tuple of tuples (default)
frappe.db.sql(query, as_dict=True) # List of dicts
frappe.db.sql(query, as_list=True) # List of lists
ALWAYS use backtick-quoted tab prefix: `tabSales Invoice`, `tabTask`
frappe.db.multisql({
'mariadb': "SELECT IFNULL(field, 0) FROM `tabDoc`",
'postgres': "SELECT COALESCE(field, 0) FROM `tabDoc`"
})
The Query Builder uses PyPika under the hood. It generates parameterized SQL automatically.
Task = frappe.qb.DocType('Task')
results = (
frappe.qb.from_(Task)
.select(Task.name, Task.subject, Task.status)
.where(Task.status == 'Open')
.orderby(Task.creation, order='desc')
.limit(10)
).run(as_dict=True)
SI = frappe.qb.DocType('Sales Invoice')
Customer = frappe.qb.DocType('Customer')
results = (
frappe.qb.from_(SI)
.inner_join(Customer).on(SI.customer == Customer.name)
.select(SI.name, SI.grand_total, Customer.customer_name)
.where(SI.docstatus == 1)
).run(as_dict=True)
from frappe.query_builder.functions import Count, Sum, Avg
stats = (
frappe.qb.from_(Task)
.select(Task.status, Count(Task.name).as_('count'))
.groupby(Task.status)
).run(as_dict=True)
customers = frappe.qb.DocType('Customer')
results = (
frappe.qb.from_(customers)
.select(customers.name)
.where(
(customers.territory == 'US') | (customers.territory == 'UK')
)
).run(as_dict=True)
query = frappe.qb.from_(Task).select('*').where(Task.name == 'X')
sql, params = query.walk() # Returns (sql_string, param_dict)
sql_str = query.get_sql() # Returns SQL string
See
references/query-patterns.mdfor subqueries, ImportMapper, ConstantColumn, and custom functions.
doc = frappe.get_cached_doc('Company', 'My Company') # Full document
val = frappe.db.get_value('Company', 'X', 'country', cache=True) # Single value
frappe.cache.set_value('key', data, expires_in_sec=3600)
data = frappe.cache.get_value('key')
frappe.cache.delete_value('key')
from frappe.utils.caching import redis_cache
@redis_cache(ttl=300)
def get_dashboard_data(user):
return expensive_calculation(user)
# Invalidate
get_dashboard_data.clear_cache()
See
references/caching-patterns.mdfor hash operations, invalidation strategies, and best practices.
The framework manages transactions automatically:
| Context | Commit | Rollback | |---------|--------|----------| | POST/PUT request | After success | On uncaught exception | | GET request | Never | — | | Background job | After success | On exception | | Patch | After success | On exception |
frappe.db.savepoint('before_payment')
try:
# operations...
frappe.db.commit()
except Exception:
frappe.db.rollback(save_point='before_payment')
frappe.db.after_commit.add(sync_to_external_system)
frappe.db.after_rollback.add(cleanup_external_state)
CRITICAL SECURITY RULE: NEVER interpolate user input into SQL strings.
# ❌ VULNERABLE — SQL injection risk
frappe.db.sql(f"SELECT * FROM `tabUser` WHERE name = '{user_input}'")
frappe.db.sql("SELECT * FROM `tabUser` WHERE name = '%s'" % user_input)
frappe.db.sql("SELECT * FROM `tabUser` WHERE name = " + user_input)
# ✅ SAFE — parameterized query
frappe.db.sql("SELECT * FROM `tabUser` WHERE name = %(name)s", {'name': user_input})
# ✅ SAFE — ORM / Query Builder (always parameterized)
frappe.get_all('User', filters={'name': user_input})
User = frappe.qb.DocType('User')
frappe.qb.from_(User).select('*').where(User.name == user_input).run()
RULE: When you MUST use frappe.db.sql(), ALWAYS use %(param)s placeholders with a dict. The Query Builder (frappe.qb) is ALWAYS preferred over raw SQL for new code.
| Feature | v14 | v15 | v16 |
|---------|-----|-----|-----|
| Query Builder (frappe.qb) | Yes | Yes | Yes |
| Transaction hooks | No | Yes | Yes |
| bulk_update | No | Yes | Yes |
| run=False returns | SQL string | SQL string | Query Builder object |
| Aggregate field syntax | String | String | Dict |
# v14/v15 — string aggregates
fields=['count(name) as count']
# v16 — dict aggregates
fields=[{'COUNT': 'name', 'as': 'count'}]
# v14/v15 — run=False returns SQL string
sql = frappe.db.get_list('Task', run=False)
# v16 — run=False returns Query Builder object
qb_obj = frappe.db.get_list('Task', run=False)
sql = qb_obj.get_sql()
frappe.db.commit() inside controller hooks (validate, on_update, etc.)page_length parameterfields=['*'] in productionfrappe.db.exists() for existence checks — NEVER try/except with get_doctab in raw SQL: `tabSales Invoice`frappe.qb over frappe.db.sql() for new codefrappe.db.truncate() without understanding it CANNOT be rolled backFor complex queries (joins, aggregations, subqueries, cross-DB compatibility), see frappe-syntax-query-builder.
tools
Use when implementing OAuth providers, Connected Apps, Webhooks, Payment Gateways, or Data Import/Export in Frappe. Prevents authentication failures from wrong OAuth flow, missed webhook deliveries, and data corruption during bulk imports. Covers OAuth2 provider/client, Connected App DocType, Webhook DocType, Payment Gateway integration, Data Import, Data Export, frappe.integrations module. Keywords: OAuth, Connected App, Webhook, Payment Gateway, Data Import, Data Export, integration, API key, OAuth2, webhook trigger, connect to external service, OAuth setup, webhook configuration, import data, export data..
development
Use when implementing hooks.py configurations in a Frappe custom app. Covers step-by-step workflows for doc_events, scheduler_events, override/extend_doctype_class, permission hooks, extend_bootinfo, fixtures, asset injection, website hooks, and doctype_js. Prevents broken transactions, missed migrations, and multi-app conflicts. Keywords: hooks.py, doc_events, scheduler_events, override doctype,, how to add hook, when to use doc_events, scheduler setup, override existing behavior. extend doctype class, permission hook, scheduler job, fixtures, doctype_js, extend_bootinfo, website hooks.
development
Use when building a custom Frappe app from scratch. Covers bench new-app walkthrough, app structure decisions, adding DocTypes, hooks, patches, fixtures management, development workflow (bench migrate, build, clear-cache), testing, packaging, installing on another site, version management, and app dependencies for v14/v15/v16. Keywords: create custom app, new frappe app, bench new-app, app structure, module creation, doctype creation, fixtures, patches, deployment, packaging, data migration, patch file, patches.txt, migrate data between DocTypes, create new app from scratch.
development
Use when building Document Controllers in a custom Frappe app: file creation, lifecycle hooks, validation, autoname, submittable workflows, controller override, child table controllers, flags system, migration from hooks.py and Server Scripts. Keywords: how to implement controller, which hook to use, validate vs on_update, override controller, submittable document, autoname, flags, extend_doctype_class, controller testing, child table controller, which hook to use, when does validate run, how to override save, document lifecycle.