🏛
Industries served
A recruiter covering Agriculture, Education, and Arts — all in one field.
📍
Geographic areas
A vendor covering Arizona, Colorado, and California stored as one field value.
💼
Job functions
A specialist handling both Business Development and Finance roles.
🏆
Seniority levels
A recruiter who places both Senior Managers and Vice Presidents.
Field:
Stored value:
This internal storage format is the root cause of every querying challenge with multi-select fields — understanding it is the key to solving them correctly.
2. Why the standard Zoho search API fails for multi-select fields
Zoho CRM's built-in What_Industries_Do_They_ServiceStored value:
"Agriculture, Food, and Natural Resources;Arts, Culture, Performance, and Entertainment;Education and Learning"zoho.crm.searchRecords() and the standard REST search endpoint rely on simple equality or basic "contains" matching. They cannot handle complex nested OR/AND logic across multiple multi-select fields simultaneously.
⚠ Common mistake: Using
Suppose you need contacts who meet all of these criteria at once:
zoho.crm.searchRecords("Contacts", "Industry:equals:Agriculture") only returns records where the field contains exactly that one string — it fails completely on multi-select picklist fields that store several values joined together.- ✓Serve at least one of: Agriculture or Arts & Culture
- ✓Cover at least one of: Arizona, Colorado, or Anywhere Nationwide
- ✓Handle at least one of: Business Development or Sales
- ✓Specialise in at least one of: Senior Manager or Vice President
- Up to 25 criteria per WHERE clause
- Up to 2,000 records per API call (paginate with OFFSET)
- All queries are POST requests to
https://www.zohoapis.com/crm/v8/coql
LIKE '%value%'. The Zoho COQL field types documentation[1] explicitly confirms LIKE as the correct comparator for multi-select picklist fields:
COQL — correct vs incorrect for multi-select fields
-- CORRECT: finds contacts where "Agriculture" appears -- anywhere in the semicolon-separated field string SELECT First_Name, Last_Name FROM Contacts WHERE What_Industries_Do_They_Service LIKE '%Agriculture, Food, and Natural Resources%' -- INCORRECT: only matches if the ENTIRE field -- contains exactly this one value and nothing else SELECT First_Name FROM Contacts WHERE What_Industries_Do_They_Service = 'Agriculture, Food, and Natural Resources'
💡 Why wildcards on BOTH sides? The leading
4. COQL vs standard search API — comparison table
% handles cases where the target is not the first item in the stored list. The trailing % handles cases where it is not the last. Per the Zoho docs[1], NOT LIKE also requires wildcards on both sides for a "not contains" check.| Feature | Standard zoho.crm.searchRecords() | COQL API |
|---|---|---|
| Multi-select picklist partial match | ✗ Not supported | ✓ LIKE '%value%' |
| Nested OR / AND logic | ✗ Very limited | ✓ Full nesting supported |
| Multiple field criteria in one call | ✗ Basic only | ✓ Up to 25 criteria |
| Dynamic query building | ✗ Cumbersome | ✓ String concat in Deluge |
| Records per API call | 200 max | ✓ Up to 2,000 |
| Aggregate functions (COUNT, SUM…) | ✗ Not available | ✓ Full support |
| ORDER BY / GROUP BY | ✗ Not available | ✓ Fully supported |
| Pagination (OFFSET) | Page-number only | ✓ LIMIT + OFFSET |
[] and JSON Set {} input formats transparently via Deluge's for each loop.
Expected input format
JSON — input body
{
"geographic_area": ["Anywhere Nationwide", "Arizona", "Colorado"],
"industries": ["Agriculture, Food, and Natural Resources",
"Arts, Culture, Performance, and Entertainment"],
"job": ["Business Development", "Sales"],
"snr_level": ["Senior Manager", "Vice President"]
}
Deluge — parse input & extract lists
string standalone.Fetch_Matching_Recruiters(String crmAPIRequest) { // Parse incoming request body resp = crmAPIRequest.toMap(); inputData = ifnull(resp.getJSON("body"), ""); // Configuration size = "20"; addUserType = true; userType = "Recruiter"; // Extract each filter into a Deluge List // for each handles BOTH {} Set and [] List formats transparently indList = List(); for each opt in inputData.get("industries") { indList.add(opt); } geoList = List(); for each opt in inputData.get("geographic_area") { geoList.add(opt); } jobList = List(); for each opt in inputData.get("job") { jobList.add(opt); } snrList = List(); for each opt in inputData.get("snr_level") { snrList.add(opt); } }
⚠ Operator precedence: COQL evaluates AND before OR (same as SQL). Without explicit parentheses around each OR group, your query can silently return wrong results.
Deluge — building OR clause for each multi-select field
// Section 1 — Industries OR clause indClause = ""; indFirst = true; for each val in indList { part = "(What_Industries_Do_They_Service like '%" + val + "%')"; if(indFirst) { indClause = part; indFirst = false; } else { indClause = "(" + indClause + " or " + part + ")"; } } // Section 2 — Geographic Areas OR clause geoClause = ""; geoFirst = true; for each val in geoList { part = "(Geographic_Areas like '%" + val + "%')"; if(geoFirst) { geoClause = part; geoFirst = false; } else { geoClause = "(" + geoClause + " or " + part + ")"; } } // Section 3 — Job Functions OR clause jobClause = ""; jobFirst = true; for each val in jobList { part = "(Job_Functions like '%" + val + "%')"; if(jobFirst) { jobClause = part; jobFirst = false; } else { jobClause = "(" + jobClause + " or " + part + ")"; } } // Section 4 — Seniority Levels OR clause snrClause = ""; snrFirst = true; for each val in snrList { part = "(Seniority_levels like '%" + val + "%')"; if(snrFirst) { snrClause = part; snrFirst = false; } else { snrClause = "(" + snrClause + " or " + part + ")"; } }
- AGeo AND Job — combine Geographic_Areas OR clause AND Job_Functions OR clause into the base
coreFilter. - BAND Seniority — appended only when
snrList.size() > 0. Skipped entirely when no seniority values are passed. - CAND User_Type — controlled by an
addUserTypeboolean flag. Set tofalseto skip entirely. - DIndustries AND coreFilter — wraps everything into the final
fullWherestring.
Deluge — combining all clauses into fullWhere
// Step A: geo AND job if(geoClause != "" && jobClause != "") { coreFilter = "(" + geoClause + " and " + jobClause + ")"; } else if(geoClause != "") { coreFilter = geoClause; } else if(jobClause != "") { coreFilter = jobClause; } else { coreFilter = ""; } // Step B: AND seniority (only when snrList is non-empty) if(snrList.size() > 0 && snrClause != "") { if(coreFilter != "") { coreFilter = "(" + coreFilter + " and " + snrClause + ")"; } else { coreFilter = snrClause; } } // Step C: AND User_Type (only when addUserType = true) if(addUserType == true && userType != "") { userTypeClause = "(User_Type = '" + userType + "')"; if(coreFilter != "") { coreFilter = "(" + coreFilter + " and " + userTypeClause + ")"; } else { coreFilter = userTypeClause; } } // Step D: industries AND coreFilter = fullWhere if(indClause != "" && coreFilter != "") { fullWhere = "(" + indClause + " and " + coreFilter + ")"; } else if(indClause != "") { fullWhere = indClause; } else if(coreFilter != "") { fullWhere = coreFilter; } else { fullWhere = "id is not null"; }
invokeurl.
Deluge — execute COQL and return count
selectQuery = "select What_Industries_Do_They_Service from Contacts where " + fullWhere + " limit " + size; queryMap = Map(); queryMap.put("select_query", selectQuery); response = invokeurl [ url : "https://www.zohoapis.com/crm/v8/coql" type : POST parameters : queryMap.toString() connection : "zcrm_coql" ]; count = response.getJSON("info").getJSON("count"); return {"count" : count};
🔔 Pagination: Per the COQL API docs[2], fetch up to 2,000 records with
LIMIT 0, 2000. For datasets beyond 100,000 records, add id > {last_record_id} to continue paging.Real-world use cases — input, generated query & expected output The three cases below show the dynamic query builder with different input formats, varying value counts, and duplicate entries. All use the same Deluge function and the same COQL endpoint[1].
Use case 1 — JSON List [], multiple values per field
Expected API response
Two industries, three geo areas (incl. "Anywhere Nationwide"), two jobs, two seniority levelsAll four fields use standard JSON List array [] notation.
✓ Output: 1 matching record
Industries[] ListAgriculture, Food, and Natural ResourcesArts, Culture, Performance, and Entertainment
Geographic area[] ListAnywhere NationwideArizonaColorado
Job functions[] ListBusiness DevelopmentSales
Seniority level[] ListSenior ManagerVice President
Deluge — Case 1 inputData
// Case 1 — JSON List [] notation inputData = { "geographic_area": ["Anywhere Nationwide", "Arizona", "Colorado"], "industries": ["Agriculture, Food, and Natural Resources", "Arts, Culture, Performance, and Entertainment"], "job": ["Business Development", "Sales"], "snr_level": ["Senior Manager", "Vice President"] };
Generated COQL query — Case 1
SELECT What_Industries_Do_They_Service FROM Contacts WHERE ((What_Industries_Do_They_Service LIKE '%Agriculture, Food, and Natural Resources%') OR (What_Industries_Do_They_Service LIKE '%Arts, Culture, Performance, and Entertainment%')) AND ((((Geographic_Areas LIKE '%Anywhere Nationwide%') OR (Geographic_Areas LIKE '%Arizona%')) OR (Geographic_Areas LIKE '%Colorado%')) AND ((Job_Functions LIKE '%Business Development%') OR (Job_Functions LIKE '%Sales%')) AND ((Seniority_levels LIKE '%Senior Manager%') OR (Seniority_levels LIKE '%Vice President%')) AND (User_Type = 'Recruiter')) LIMIT 20
{
"data": [
{ "What_Industries_Do_They_Service": "Agriculture, Food, and Natural Resources;Arts, Culture...",
"id": "5725767000000678203" }
],
"info": { "count": 1, "more_records": false }
}
💡 Why output = 1
- All four OR clauses are AND-ed together — only records matching at least one value from every field pass the filter.
- With
addUserType = true, the query also filters byUser_Type = 'Recruiter', making conditions very specific. - "Anywhere Nationwide" is the most permissive geo option, yet the strict seniority + industry combination limits results to 1 record.
Use case 2 — JSON Set {}, single/few values per field
Expected API response
One industry, four specific states, two jobs, one seniority levelAll fields use JSON Set {} curly-brace notation instead of List arrays.
✓ Output: 2 matching records
Industries{} SetAgriculture, Food, and Natural Resources
Geographic area{} SetAlabamaAlaskaCaliforniaColorado
Job functions{} SetBusiness DevelopmentFinance
Seniority level{} SetSenior Manager
Deluge — Case 2 inputData
// Case 2 — JSON Set {} notation inputData = { "geographic_area": {"Alabama", "Alaska", "California", "Colorado"}, "industries": {"Agriculture, Food, and Natural Resources"}, "job": {"Business Development", "Finance"}, "snr_level": {"Senior Manager"} };
Generated COQL query — Case 2
SELECT What_Industries_Do_They_Service FROM Contacts WHERE (What_Industries_Do_They_Service LIKE '%Agriculture, Food, and Natural Resources%') AND ((((Geographic_Areas LIKE '%Alabama%') OR (Geographic_Areas LIKE '%Alaska%')) OR (Geographic_Areas LIKE '%California%')) OR (Geographic_Areas LIKE '%Colorado%')) AND ((Job_Functions LIKE '%Business Development%') OR (Job_Functions LIKE '%Finance%')) AND (Seniority_levels LIKE '%Senior Manager%') AND (User_Type = 'Recruiter')) LIMIT 20
{
"data": [
{ "What_Industries_Do_They_Service": "Agriculture, Food, and Natural Resources", "id": "5725767000000678210" },
{ "What_Industries_Do_They_Service": "Agriculture, Food, and Natural Resources;Education...", "id": "5725767000000678215" }
],
"info": { "count": 2, "more_records": false }
}
💡 Why output = 2 — and why Set {} works like List []
- Deluge's
for eachiterates JSON Set{}and JSON List[]transparently — no type-checking needed. - Industry has one value so its clause is a single LIKE condition — no OR wrapping needed.
- Four specific US states plus "Finance" as a job broadens the match — 2 recruiter contacts satisfy all conditions.
Use case 3 — JSON List [], duplicate values in seniority
Expected API response
Two industries, four geo areas, two jobs — seniority list contains "Vice President" twiceShows how duplicate input values affect the generated query and why they don't change results but consume criteria slots.
✓ Output: 2 matching records
Industries[] ListAgriculture, Food, and Natural ResourcesArts, Culture, Performance, and Entertainment
Geographic area[] ListAlabamaAlaskaCaliforniaColorado
Job functions[] ListBusiness DevelopmentFinance
Seniority level[] ListSenior ManagerVice PresidentVice President ⚠ duplicateSenior Vice President
Deluge — Case 3 inputData
// Case 3 — List [] with duplicate "Vice President" in snr_level inputData = { "geographic_area": ["Alabama", "Alaska", "California", "Colorado"], "industries": ["Agriculture, Food, and Natural Resources", "Arts, Culture, Performance, and Entertainment"], "job": ["Business Development", "Finance"], "snr_level": ["Senior Manager", "Vice President", "Vice President", // ← duplicate entry "Senior Vice President"] };
Generated COQL query — Case 3 (duplicate highlighted)
SELECT What_Industries_Do_They_Service FROM Contacts WHERE ((What_Industries_Do_They_Service LIKE '%Agriculture, Food, and Natural Resources%') OR (What_Industries_Do_They_Service LIKE '%Arts, Culture, Performance, and Entertainment%')) AND ((((Geographic_Areas LIKE '%Alabama%') OR (Geographic_Areas LIKE '%Alaska%')) OR (Geographic_Areas LIKE '%California%')) OR (Geographic_Areas LIKE '%Colorado%')) AND ((Job_Functions LIKE '%Business Development%') OR (Job_Functions LIKE '%Finance%')) AND ((((Seniority_levels LIKE '%Senior Manager%') OR (Seniority_levels LIKE '%Vice President%')) OR (Seniority_levels LIKE '%Vice President%')) ← duplicate, no effect on results OR (Seniority_levels LIKE '%Senior Vice President%')) AND (User_Type = 'Recruiter')) LIMIT 20
{
"data": [
{ "What_Industries_Do_They_Service": "Agriculture, Food, and Natural Resources", "id": "5725767000000678210" },
{ "What_Industries_Do_They_Service": "Agriculture, Food, and Natural Resources;Arts, Culture...", "id": "5725767000000678224" }
],
"info": { "count": 2, "more_records": false }
}
💡 Why output = 2 — and what the duplicate does
- The duplicate generates a redundant
LIKE '%Vice President%'— zero effect on results inside an OR group. - However, it counts toward the 25-criteria limit — strongly recommended to deduplicate input lists in production.
- Addition of "Senior Vice President" and the broader state-level geo scope explains 2 matching records vs 1 in Case 1.
Use case comparison summary
Case 11List [], "Anywhere Nationwide" geo, strict seniority
Case 22Set {}, 4 specific states, single industry & seniority
Case 32List [], duplicate VP entry, broader seniority scope
| Factor | Case 1 | Case 2 | Case 3 |
|---|---|---|---|
| Input format | JSON List [] | JSON Set {} | JSON List [] |
| Industries | 2 values | 1 value | 2 values |
| Geo filter | 3 (incl. Nationwide) | 4 specific states | 4 specific states |
| Job filter | Sales + Biz Dev | Biz Dev + Finance | Biz Dev + Finance |
| Seniority filter | Sr Manager + VP | Sr Manager only | Sr Mgr + VP + VP(dup) + Sr VP |
| Duplicate values | None | None | Yes — "VP" ×2 |
| Output count | 1 | 2 | 2 |
LIKE '%value%' operator that the official Zoho documentation[1] confirms as the correct comparator for multi-select fields.
🎯 Key takeaways
- Multi-select fields store all values as one semicolon-separated string — exact-match
=will not work. - Use
LIKE '%value%'with wildcards on both sides to match at any position in the stored string. - Build one OR clause per field by iterating selected values and wrapping each in parentheses.
- Combine all field OR clauses with AND — explicit parentheses enforce correct operator precedence.
- Deluge's
for eachhandles both JSON Set{}and JSON List[]without any extra type-checking. - Make optional filters conditional using size checks and boolean flags — empty inputs are skipped cleanly.
- Deduplicate input lists in production — duplicates consume criteria slots (max 25 per WHERE).
- COQL supports up to 2,000 records per call with full OFFSET pagination.
Frequently asked questions
Can I use this approach for multi-select fields in modules other than Contacts?+
Yes. Replace
Contacts in the SELECT clause with any module API name such as Leads, Deals, or a custom module. The LIKE-based OR pattern works for any multi-select picklist in any module. Refer to the Zoho COQL field types documentation for all supported comparators.What is the maximum number of records COQL returns per call?+
Per the COQL API Overview, you can retrieve up to 2,000 records per call using
LIMIT 0, 2000. For larger datasets paginate using OFFSET. Beyond 100,000 records, add id > {last_record_id} to your WHERE clause.Why does each OR condition need its own parentheses?+
COQL evaluates AND before OR — same precedence as SQL. Without parentheses around each OR group, the database applies AND first and produces incorrect results. Parentheses ensure OR options for one field are grouped before the AND between fields is applied.
What happens if the user passes no values for a field?+
Each clause variable stays as an empty string
"". The combination logic skips empty clauses entirely. If all clauses are empty the function falls back to WHERE id is not null, returning all records up to the LIMIT.How do I set up the zcrm_coql OAuth connection?+
Go to Zoho CRM → Setup → Developer Space → Connections → New Connection. Select Zoho CRM, add scopes
ZohoCRM.modules.contacts.READ and ZohoCRM.coql.READ, name the connection zcrm_coql. This must exactly match the connection parameter in the invokeurl block.Should I deduplicate input values before passing them to the function?+
Yes — strongly recommended for production. As shown in Use Case 3, a duplicate value generates a redundant LIKE condition that has no effect on results but does count toward the 25-criteria limit per WHERE clause.
Can I return the full record data instead of just the count?+
Yes. Access
response.getJSON("data") to retrieve the full list of matching records. Add more fields to the SELECT clause to include additional CRM field values in each returned record.References All links below point to official Zoho documentation and the official Zoho developer community.
- Get Records through COQL Query — Zoho CRM API v8https://www.zoho.com/crm/developer/docs/api/v8/Get-Records-through-COQL-Query.htmlPrimary reference: field types, LIKE operator for multi-select picklist, sample queries, API error codes.
- Query (COQL) API Overview — Zoho CRM API v8https://www.zoho.com/crm/developer/docs/api/v8/COQL-Overview.htmlFull COQL syntax: SELECT, WHERE, ORDER BY, LIMIT, OFFSET, GROUP BY, aggregate functions, 25-criteria limit, pagination.
- Subquery in Query (COQL) API — Zoho CRM API v8https://www.zoho.com/crm/developer/docs/api/v8/COQL-subquery.htmlAdvanced: IN, NOT IN, BETWEEN operators and nested subqueries.
- Kaizen #80 — COQL API Part I — Zoho Communityhttps://help.zoho.com/portal/en/community/topic/kaizen-80-coql-api-part-iOfficial Zoho developer community deep-dive with real-world COQL examples and best practices.
- Kaizen #171 — FAQ on COQL API — Zoho Communityhttps://help.zoho.com/portal/en/community/topic/kaizen-171-frequently-asked-questions-on-coql-apiOfficial Zoho FAQ: multi-select field queries, operator limits, error resolution, debugging tips.