
This is a Servoy tutorial on database array columns in PostgreSQL. Storing a short list of tags, roles, or categories per record has always required either a child table (with all its JOINs) or a delimited string column (with all its parsing headaches). As of Servoy 2025.06, there is a third option: a native array column that your foundset reads and writes as a plain JavaScript array.
If you have read my Modern JavaScript in Servoy tutorial [3], you are already writing the ES6+ code this feature rewards. If you haven’t read it yet, now is a good time.
Creating an array column
Here is the thing about array columns in Servoy: they do not exist as a type you select in the table editor. The standard Servoy column type abstraction covers DATETIME, INTEGER, MEDIA, NUMBER, TEXT, and UUID. There is no “Array” selector in that list. Array columns are a PostgreSQL-level feature, and Servoy surfaces them by reading the table metadata after you create the column via DDL.
Support for array columns shipped in Servoy 2025.06 (ticket SVY-20207), available in PostgreSQL and HSQLDB [1]. For production use, PostgreSQL is the one you care about. HSQLDB support exists but the release notes do not provide HSQLDB-specific syntax, so this article focuses on PostgreSQL.
You define the column in a migration script or directly in the PostgreSQL console:
CREATE TABLE crm_product ( prod_id SERIAL PRIMARY KEY, prod_name TEXT NOT NULL, tags TEXT[], org_id INTEGER NOT NULL);The TEXT[] syntax is standard PostgreSQL array notation. The Servoy release notes document INTEGER[] and TEXT[] as the confirmed element types [1]. Other PostgreSQL-native array types (BOOLEAN[], NUMERIC[], DATE[]) follow standard PostgreSQL array conventions, but only INTEGER[] and TEXT[] appear in the official examples, so hedge if you go beyond those two.
Once the DDL is applied, Servoy picks up the tags column automatically when it reads table metadata. No Servoy Developer configuration is needed beyond that.
Reading and writing as JavaScript arrays
The ORM handles PostgreSQL array serialization transparently. From the foundset side, reading and writing an array column looks exactly like any other dataprovider:
/** * Set the initial tags for the current product record. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @return {void} */function setInitialTags() { try { /**@type {Array<String>}*/ const aInitialTags = ['featured', 'new-arrival']; foundset.tags = aInitialTags;
/**@type {Boolean}*/ const bSaved = databaseManager.saveData(); if (!bSaved) { databaseManager.rollbackEditedRecords(); application.output('saveData() failed in setInitialTags', LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Save Error', 'Could not save product tags. Please try again.', 'OK'); } } catch (oError) { application.output('Error in setInitialTags: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'An unexpected error occurred while setting tags.', 'OK'); }}Reading is equally direct:
/** * Get the tags for the current product record. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @return {Array<String>} */function getProductTags() { /**@type {Array<String>}*/ const aTags = foundset.tags ?? []; return aTags;}The ?? [] guard handles the case where tags is null on a new record before any tags have been set. That’s it. No JSON parsing, no split-on-delimiter, no special plugin call. The foundset dataprovider returns a JavaScript array and accepts one back.
The sealed-array quirk
Here is where things get interesting, and where you need to pay attention if you are upgrading an existing application.
When Servoy returns an array from a record dataprovider, that array is sealed [1]. The release notes are explicit about why:
“Because of the Database Array support the arrays that are stored in the record are sealed now. This is because changing the contents of an array that is stored in the record is not supported. If you want to change the contents of an array, you need to make a copy (slice(0)) and then alter that and set the array back” [1].
What this means in practice: if you do foundset.tags.push('sale'), Servoy will throw an error. The sealed array cannot be mutated in place. The correct idiom is slice-copy-and-reassign:
/** * Add a tag to the current product record. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {String} sTag The tag to add. * @return {void} */function addTag(sTag) { try { /**@type {Array<String>}*/ const aCurrent = foundset.tags ? foundset.tags.slice(0) : [];
if (aCurrent.indexOf(sTag) === -1) { aCurrent.push(sTag); foundset.tags = aCurrent;
/**@type {Boolean}*/ const bSaved = databaseManager.saveData(); if (!bSaved) { databaseManager.rollbackEditedRecords(); application.output('saveData() failed in addTag', LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Save Error', 'Could not save the tag. Please try again.', 'OK'); } } } catch (oError) { application.output('Error in addTag: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'An unexpected error occurred while adding the tag.', 'OK'); }}The three steps are: slice(0) to copy, mutate the copy, then assign the copy back to the dataprovider.
Removing a tag follows the same pattern:
/** * Remove a tag from the current product record. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {String} sTag The tag to remove. * @return {void} */function removeTag(sTag) { try { /**@type {Array<String>}*/ const aCurrent = foundset.tags ? foundset.tags.slice(0) : []; /**@type {Number}*/ const nIndex = aCurrent.indexOf(sTag);
if (nIndex !== -1) { aCurrent.splice(nIndex, 1); foundset.tags = aCurrent;
/**@type {Boolean}*/ const bSaved = databaseManager.saveData(); if (!bSaved) { databaseManager.rollbackEditedRecords(); application.output('saveData() failed in removeTag', LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Save Error', 'Could not remove the tag. Please try again.', 'OK'); } } } catch (oError) { application.output('Error in removeTag: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'An unexpected error occurred while removing the tag.', 'OK'); }}One more thing: the sealed-array rule also applies to JSON arrays stored in string columns via StringSerializer. If your application uses StringSerializer to persist arrays as JSON strings in a TEXT column, those returned arrays are now sealed too [1]. The same slice(0) idiom applies. This is the part of the breaking change that is easy to miss on upgrade.
Query limitations: exact match only
This is the part where the feature shows its current limits.
As of 2026.03, QBSelect can only match an array column against a whole array exactly [1]. That means you can query for records where tags = ['featured', 'new-arrival'] as a whole-array equality check, but you cannot query for records where tags contains the value 'featured'. There is no QBArrayColumn subclass in the typed Query Builder, and there is no element-level operator exposed through QBSelect.
The 2025.06 release notes are direct about this:
“Currently, arrays must match exactly for querying purposes. Potential future enhancements could allow searching individual array values” [1].
If you need to search within array elements, the workaround is plugins.rawSQL with native PostgreSQL array operators:
/** * Load all products that include the given tag, using a raw SQL element search. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {String} sTag The tag to search for. * @return {void} */function loadProductsByTag(sTag) { try { /**@type {String}*/ const sSQL = 'SELECT prod_id FROM crm_product WHERE ? = ANY(tags) AND org_id = ?';
/**@type {JSDataSet}*/ const dsPks = databaseManager.getDataSetByQuery('myserver', sSQL, [sTag, globals.org_id], -1); // -1 returns all matching rows
// dsPks has a single PK column (prod_id), which is exactly what loadRecords expects. foundset.loadRecords(dsPks); } catch (oError) { application.output('Error in loadProductsByTag: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'Could not search products by tag.', 'OK'); }}The ANY() operator is PostgreSQL native and does the element search the typed query builder cannot. Two API details matter here. First, retrieving rows with raw SQL uses databaseManager.getDataSetByQuery(server, sql, args, maxRows), which returns a JSDataSet. Do not reach for plugins.rawSQL.executeSQL to fetch rows: that method returns a Boolean and is only for writes and DDL (INSERT/UPDATE/DELETE). Second, when the SELECT returns the table’s primary key, you can hand the resulting dataset straight to foundset.loadRecords(dsPks); there is no need to loop the rows or convert anything by hand. The tradeoff of the raw SQL route is that it runs outside the typed-query layer, so it is harder to refactor and loses the type safety the QBSelect typed columns give you.
A realistic scenario: tags on a product
Let’s put this into context with something concrete. You are building a product catalog in a CRM. Each product can have multiple tags: 'featured', 'sale', 'new-arrival', 'clearance'. You need to store these tags and display them in the UI.
The child-table version
The traditional approach is a crm_product_tag child table:
CREATE TABLE crm_product_tag ( tag_id SERIAL PRIMARY KEY, prod_id INTEGER NOT NULL REFERENCES crm_product(prod_id), tag_name TEXT NOT NULL, org_id INTEGER NOT NULL);Loading tags for the current product uses a standard relation. You can query “all products with the ‘featured’ tag” with a clean QBSelect join, enforce referential integrity on product IDs, and use the full power of SQL for reporting. A query using the typed Query Builder looks like this:
/** * Load all featured products via the child-table relation. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @return {void} */function loadFeaturedProducts() { try { /**@type {QBSelect<db:/myserver/crm_product>}*/ const query = datasources.db.myserver.crm_product.createSelect();
query.result.addPk();
/**@type {QBJoin<db:/myserver/crm_product_tag>}*/ const joinTag = query.joins.add('db:/myserver/crm_product_tag', QBJoin.INNER_JOIN, 'tag'); joinTag.on.add(joinTag.columns.prod_id.eq(query.columns.prod_id));
query.where.add(joinTag.columns.tag_name.eq('featured')); query.where.add(query.columns.org_id.eq(globals.org_id));
foundset.loadRecords(query); } catch (oError) { application.output('Error in loadFeaturedProducts: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'Could not load featured products.', 'OK'); }}That is clean, type-safe, and searchable. The downside: every tag add or remove is a separate child record. If a product’s tag list changes frequently and you never need to search by tag, the child table starts to feel like a lot of plumbing for a small list.
The array-column version
With a tags TEXT[] column on crm_product, setting a new product’s initial tags is a single assignment:
/** * Initialize default tags for a new product record. * @author Gary Dotzlaw * @since 2026-06-11 * @public * @return {void} */function initProductTags() { try { /**@type {Array<String>}*/ const aDefaultTags = ['new-arrival']; foundset.tags = aDefaultTags;
/**@type {Boolean}*/ const bSaved = databaseManager.saveData(); if (!bSaved) { databaseManager.rollbackEditedRecords(); application.output('saveData() failed in initProductTags', LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Save Error', 'Could not save product tags.', 'OK'); } } catch (oError) { application.output('Error in initProductTags: ' + oError.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'An unexpected error occurred.', 'OK'); }}Checking whether the current product is featured requires no database query at all:
/** * Check if the current product has the 'featured' tag (in-memory check). * @author Gary Dotzlaw * @since 2026-06-11 * @public * @return {Boolean} */function isFeatured() { /**@type {Array<String>}*/ const aTags = foundset.tags ?? []; return aTags.indexOf('featured') !== -1;}That’s the tradeoff in a nutshell. The array version is simpler and faster for in-memory checks. But searching across records for products with the 'featured' tag requires the raw SQL workaround shown above, not a QBSelect query.
When to use array columns
Use array columns when these conditions all hold:
- The list is bounded and small (under 50 elements is a reasonable guide).
- You do not need to query by element across records in everyday application queries. In-memory checks like
isFeatured()are fine; cross-record searches in batch reports are not. - The elements do not reference other entities. Tags like
'featured'and'sale'are freeform strings. If the values were product category IDs pointing to acrm_categorytable, you need a child table for referential integrity. - Write churn is low to moderate. The
slice(0)copy-and-reassign overhead is negligible for occasional tag updates but is worth considering for batch operations that update tags on thousands of records.
The bottom-line: array columns are excellent for bounded, freeform, read-mostly sets that you display or check in-memory, with no search requirement. Permission arrays per user (['admin', 'reports', 'billing']), product status flags, and multi-category labels all fit this profile.
When to use a child table
Use a child table when any of the following apply:
- You need to search by element. “Show me all products tagged ‘featured’” is a query you want to write in QBSelect, not in raw SQL. If this is a common application query, use a child table.
- Cardinality is unbounded. A product can have 3-5 tags. A knowledge base article can have 50. A mailing list member can have 300 audience segments. At some point the array pattern stops being a simplification.
- Elements reference other entities. If each tag is actually a link to a managed
crm_tagrecord with its own display name, colour, and visibility rules, you need a child table with a foreign key. - High-churn writes. If your batch processor adds and removes elements from these arrays continuously for large record sets, the overhead of loading, copying, mutating, and saving each array individually will hurt throughput.
- Reporting. SQL reporting tools that query the database directly work well with normalized child tables and struggle with array columns unless the reporting tool supports PostgreSQL array syntax.
Binding to a multi-select UI component (2025.12)
If you are on Servoy 2025.12 or later, there is a bonus worth knowing about. Servoy 2025.12 (ticket SVY-20565) introduced a multiselect property on certain UI components that lets you bind the component directly to an array dataprovider [2]:
“For certain UI components we introduced a
multiselectproperty. It allows these components to be bound directly to an Array data-provider (columns and variables)” [2]. “This means that developers no longer need to bind to a string and split on a delimiter (i.e. \n) as before, but can directly connect the component to the Array column/variable” [2].
Before 2025.12, if you wanted a multi-select dropdown for a tag field, you had to bind it to a string column and parse the delimiter on read and write. That meant choosing a delimiter that would never appear in your values, handling edge cases, and keeping the string representation in sync with whatever display component the user interacted with. Not pretty.
With 2025.12+, you set the component’s multiselect property and bind it directly to the tags column, so you no longer manage the string-splitting or the array assignment yourself. The version gate here is important: the multiselect property is not available on 2025.06, where array columns first landed. Plan accordingly if your deployment targets an earlier version.
What comes next
Array columns and vector columns are both database-level features that landed in the 2025 release wave and work together in AI-enabled applications. An array column holding a list of category tags is exactly the kind of structured metadata you combine with a semantic embedding search to build a hybrid retrieval system. If that sounds interesting, my Embedding Data with Servoy AI [4] and Hybrid Queries in Servoy AI [5] tutorials walk through the embedding storage and query patterns that sit alongside this one.
Conclusion
That concludes this Servoy tutorial on database array columns. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.
References
[1] Servoy, “2025.06 Release Notes,” docs.servoy.com, June 2025. https://docs.servoy.com/release-notes/release-notes/2025.06
[2] Servoy, “2025.12 Release Notes,” docs.servoy.com, January 2026. https://docs.servoy.com/release-notes/release-notes/2025.12
[3] G. Dotzlaw, “Modern JavaScript in Servoy (Tutorial #17),” dotzlaw.com. /insights/servoy-tutorial-17-modern-javascript/
[4] G. Dotzlaw, “Embedding Data with Servoy AI (Tutorial #3),” dotzlaw.com. /insights/servoy-tutorial-03-embedding-data/
[5] G. Dotzlaw, “Hybrid Queries in Servoy AI (Tutorial #4),” dotzlaw.com. /insights/servoy-tutorial-04-hybrid-queries/
Building production AI, or modernizing a legacy system?
That is the kind of work we do at Dotzlaw Consulting. Book a free 20-minute intro call and tell us what you are trying to build, or what is slowing you down.