MADATA / DEV developer docs Français madata.africa

Reading data

One method covers 90 % of the need: search_read. It searches and returns the requested fields in one round trip. The other read methods exist for the remaining 10 %.

01search_read, the right reflex

pythonthe ten most recent customers
customers = models.execute_kw(DB, uid, KEY, 'res.partner', 'search_read',
    [[['customer_rank', '>', 0]]],                     # domain
    {'fields': ['name', 'email', 'phone', 'city'],     # fields wanted
     'limit': 10,
     'order': 'create_date desc'})
Always pass fields

Without fields, the workspace returns every field of every row, computed ones included. On account.move that multiplies both the response size and the call duration by ten. Ask for what you need, nothing more.

02Domains

A domain is a list of conditions. Each condition is a triplet [field, operator, value]. Side by side, they combine with an implicit AND.

pythonimplicit AND
# posted customer invoices above 500,000 FCFA
[['move_type', '=', 'out_invoice'],
 ['state', '=', 'posted'],
 ['amount_total', '>', 500000]]

For an OR, or a negation, the operator goes before the conditions it governs, and it takes two of them (one for !).

pythonOR and negation
# name = "Kouassi"  OR  email contains "kouassi"
['|', ['name', '=', 'Kouassi'], ['email', 'ilike', 'kouassi']]

# three branches: one more OR per extra condition
['|', '|', ['a', '=', 1], ['b', '=', 2], ['c', '=', 3]]

# everything but drafts
['!', ['state', '=', 'draft']]

# customers in Abidjan OR Bouake, and active
['&', ['active', '=', True],
 '|', ['city', '=', 'Abidjan'], ['city', '=', 'Bouake']]
Read a domain out loud

The operator announces what follows: "OR of (this) and (that)". Read left to right like a sentence, nested domains stop being unreadable. And when one OR governs three conditions, you need two of them in a row.

03Operators

OperatorEffect
= · !=Equal, different.
> · >= · < · <=Comparisons, dates included.
in · not inMembership. ['state', 'in', ['draft', 'sent']].
like · not likeContains, case sensitive.
ilike · not ilikeContains, case insensitive. The right pick for a text search.
=like · =ilikeFull pattern, with % and _ up to you.
child_ofThe record and its descendants: a contact and its child contacts, a location and its sublocations.
parent_ofThe record and its ancestors.

On a many2one field, a direct comparison takes the id (['partner_id', '=', 42]); an ilike works on the label (['partner_id', 'ilike', 'Kouassi']). And a dot walks the relation: ['partner_id.city', '=', 'Abidjan'].

04The other read methods

MethodWhen
searchYou only want the id, to hand them to an action.
readYou already have the id and want fields. read([[1, 2, 3]], {'fields': [...]}).
search_countYou only want the number. Do not fetch a thousand rows to count a thousand.
name_searchYou search the way an input field does: name_search('kou', limit=8) returns [id, label] pairs.
read_groupYou want totals, not rows.
fields_getYou want to know what the model holds. See Models & fields.

05Group and total

read_group does the arithmetic on the server side. That is the difference between a one second answer and a thirty thousand row export to add up at home.

pythonrevenue per customer, year to date
totals = models.execute_kw(DB, uid, KEY, 'account.move', 'read_group',
    [[['move_type', '=', 'out_invoice'],
      ['state', '=', 'posted'],
      ['invoice_date', '>=', '2026-01-01']],
     ['amount_total_signed'],      # aggregated fields
     ['partner_id']],              # grouping
    {'lazy': False, 'orderby': 'amount_total_signed desc', 'limit': 20})

for row in totals:
    print(row['partner_id'][1], row['amount_total_signed'], row['__count'])

Every result row carries the grouping value, the requested aggregates, a __count, and a __domain ready to be handed back to search_read for the detail of that group.

Group by month

Add the granularity to the grouping field: ['invoice_date:month'], or :week, :quarter, :year. And pass several fields to cross two axes, for instance ['partner_id', 'invoice_date:month'] with lazy: False.

06Pagination & sorting

limit and offset slice, order sorts with the usual SQL syntax.

pythonwalk a large model without loading it at once
PAGE = 500
offset = 0
while True:
    batch = models.execute_kw(DB, uid, KEY, 'account.move.line', 'search_read',
        [[['parent_state', '=', 'posted']]],
        {'fields': ['date', 'account_id', 'debit', 'credit'],
         'limit': PAGE, 'offset': offset, 'order': 'id'})
    if not batch:
        break
    handle(batch)
    offset += PAGE
Sort on a stable field

Paginating without order, or sorting on a moving date, skips or repeats rows between two pages. order: 'id' is boring and correct.

07The context

The context is a dictionary passed alongside the arguments. It does not change what you ask for, it changes how the workspace answers.

KeyEffect
langThe language of returned labels: fr_FR, en_US. Technical values never change.
tzThe timezone used by computed fields that depend on it. datetime values stay UTC in the response.
active_testFalse to also see archived records, which the workspace hides by default.
allowed_company_idsThe company scope of the call. See multi company.
pythoncontext: archived included, labels in English
everything = models.execute_kw(DB, uid, KEY, 'product.product', 'search_read',
    [[]],
    {'fields': ['name', 'active'],
     'context': {'active_test': False, 'lang': 'en_US'}})

08Images & attachments

Binary fields come back base64 encoded. A product image reads like any other field.

pythonfetch a product image
import base64

[product] = models.execute_kw(DB, uid, KEY, 'product.product', 'read',
    [[512]], {'fields': ['name', 'image_1920']})

if product['image_1920']:
    with open('product.png', 'wb') as f:
        f.write(base64.b64decode(product['image_1920']))

Attached documents live in ir.attachment: filter on res_model and res_id to find those of a record, then read datas. Mind the volume: only ask for datas on the files you will actually download.