Handle complex data transformations in Retool using standalone transformers in the Code tab. Chain .map(), .filter(), and .reduce() for reshaping. Join two queries using a lookup object built from one query's data. Use the bundled Lodash library (_ variable) for grouping, ordering, and multi-source merges. Transformers must return a value and are read-only.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Complex Data Reshaping Patterns for Retool Apps
Most real-world Retool apps need to combine, reshape, and aggregate data from multiple sources. A sales dashboard might join user records with order history, group by month, and compute running totals. An operations tool might merge data from two APIs and filter by business rules.
This tutorial covers the transformation patterns that power these scenarios: array method chains for single-source reshaping, lookup map joins for merging two queries, reduce-based aggregations for statistics, Lodash utilities for complex grouping, and _.zipWith for parallel array merges. All examples use Retool's standalone transformer pattern.
Prerequisites
- Understanding of Retool standalone transformers (Code tab → Transformer)
- JavaScript knowledge: array map, filter, reduce methods
- At least one query returning data in your Retool app
- Optional: familiarity with Lodash (bundled as _ in Retool)
Step-by-step guide
Single-Source Transformation: Map/Filter/Reduce Chains
The most common transformation pattern chains .filter(), .map(), and other array methods to reshape a single query's output. Always start with a null guard, then chain operations in a logical order: filter first (reduces dataset), then map (transforms remaining rows).
1// Transformer: processedOrders2// Input: query1.data = [{ id, user_id, amount, status, created_at, items: [...] }]34const orders = query1.data || [];56return orders7 // 1. Filter: only completed orders8 .filter(order => order.status === 'completed')9 // 2. Map: reshape and add computed fields10 .map(order => ({11 id: order.id,12 userId: order.user_id,13 amount: Number(order.amount || 0),14 amountFormatted: Number(order.amount || 0).toLocaleString('en-US', {15 style: 'currency', currency: 'USD'16 }),17 itemCount: Array.isArray(order.items) ? order.items.length : 0,18 createdDate: moment(order.created_at).format('MMM D, YYYY'),19 dayOfWeek: moment(order.created_at).format('dddd')20 }))21 // 3. Sort by amount descending22 .sort((a, b) => b.amount - a.amount);Expected result: The transformer returns a filtered, reshaped, and sorted array of order objects.
Join Two Queries Using a Lookup Map
The most efficient way to join two datasets in JavaScript is building a lookup object from one, then using it while mapping over the other. This is O(n+m) compared to O(n*m) for nested loops — crucial when both datasets have thousands of rows. Example: join users (query1) with their aggregate stats (query2).
1// Transformer: usersWithStats2// query1.data = [{ id, name, email, department }]3// query2.data = [{ user_id, total_orders, total_revenue, avg_rating }]45const users = query1.data || [];6const stats = query2.data || [];78// Build lookup map: O(n)9const statsMap = stats.reduce((map, stat) => {10 map[stat.user_id] = stat;11 return map;12}, {});13// OR with lodash: const statsMap = _.keyBy(stats, 'user_id');1415// Join: O(m)16return users.map(user => {17 const userStats = statsMap[user.id] || {};18 return {19 ...user,20 totalOrders: userStats.total_orders ?? 0,21 totalRevenue: Number(userStats.total_revenue ?? 0),22 avgRating: Number(userStats.avg_rating ?? 0).toFixed(1),23 isHighValue: (userStats.total_revenue ?? 0) >= 100024 };25});Expected result: Each user object is enriched with their stats data from the second query, with safe fallbacks for users with no stats.
Aggregate Data with Reduce
Use .reduce() to compute aggregated statistics across all rows: totals, averages, counts, and maximums. Return an object with computed metrics for binding to stat cards, charts, or summary rows.
1// Transformer: orderStats2// Computes summary statistics for a dashboard34const orders = query1.data || [];56if (orders.length === 0) {7 return { total: 0, count: 0, average: 0, maxOrder: 0 };8}910const stats = orders.reduce((acc, order) => {11 const amount = Number(order.amount || 0);12 return {13 total: acc.total + amount,14 count: acc.count + 1,15 maxOrder: Math.max(acc.maxOrder, amount),16 completedCount: acc.completedCount + (order.status === 'completed' ? 1 : 0),17 pendingCount: acc.pendingCount + (order.status === 'pending' ? 1 : 0)18 };19}, { total: 0, count: 0, maxOrder: 0, completedCount: 0, pendingCount: 0 });2021return {22 ...stats,23 average: stats.count > 0 ? stats.total / stats.count : 0,24 completionRate: stats.count > 025 ? (stats.completedCount / stats.count * 100).toFixed(1) + '%'26 : '0%'27};2829// Access in components:30// {{ orderStats.value.total.toLocaleString('en-US', {style:'currency',currency:'USD'}) }}31// {{ orderStats.value.completionRate }}Expected result: orderStats.value contains an object with total revenue, count, average, and completion rate.
Group Data with Lodash _.groupBy
Use _.groupBy() to split an array into groups by a field value. This is useful for building section headers, grouped charts, or per-category summaries. Lodash is bundled in Retool as the _ variable — no import needed.
1// Transformer: ordersByDepartment2// Groups users by department for a grouped table34const users = query1.data || [];56// Group by department:7const grouped = _.groupBy(users, 'department');8// Result: { Engineering: [...], Sales: [...], Marketing: [...] }910// Convert to array of group objects with summary stats:11return Object.entries(grouped)12 .map(([department, members]) => ({13 department: department || 'Unassigned',14 memberCount: members.length,15 activeCount: members.filter(m => m.is_active).length,16 totalRevenue: members.reduce((sum, m) => sum + Number(m.revenue || 0), 0),17 members: members.map(m => m.name).join(', ')18 }))19 .sort((a, b) => b.totalRevenue - a.totalRevenue);2021// For a chart: bind to bar chart with department on x, totalRevenue on yExpected result: The transformer returns an array of department summaries, each with member count and total revenue.
Merge Parallel Arrays with _.zipWith
When you have two arrays of the same length representing different metrics for the same items (e.g. category names from one query and revenue figures from another), use _.zipWith() to merge them into combined objects. This is common when APIs return separate arrays for labels and values.
1// Transformer: chartData2// query1.data = [{ month: 'Jan', year: 2024 }, { month: 'Feb' }, ...]3// query2.data = [{ revenue: 12450 }, { revenue: 18200 }, ...]4// Both arrays are same length, ordered identically56const months = query1.data || [];7const revenues = query2.data || [];89// Merge parallel arrays:10return _.zipWith(months, revenues, (month, rev) => ({11 label: `${month.month} ${month.year}`,12 revenue: Number(rev?.revenue || 0),13 revenueFormatted: Number(rev?.revenue || 0).toLocaleString('en-US', {14 style: 'currency', currency: 'USD', minimumFractionDigits: 015 })16}));1718// Alternative without Lodash:19// return months.map((month, i) => ({20// label: month.month + ' ' + month.year,21// revenue: Number(revenues[i]?.revenue || 0)22// }));Expected result: chartData.value returns an array of combined label+revenue objects ready for a chart component.
Create Running Totals and Cumulative Sums
Use .reduce() to compute running totals or cumulative sums across an ordered array. This is useful for cumulative revenue charts, stock level tracking, or running balance displays.
1// Transformer: cumulativeRevenue2// Adds a running total to each row34const salesByDay = query1.data || [];56let runningTotal = 0;78return salesByDay.map(day => {9 runningTotal += Number(day.revenue || 0);10 return {11 ...day,12 dailyRevenue: Number(day.revenue || 0),13 cumulativeRevenue: runningTotal,14 cumulativeFormatted: runningTotal.toLocaleString('en-US', {15 style: 'currency', currency: 'USD', minimumFractionDigits: 016 })17 };18});1920// For a Chart with two series:21// Series 1: x = date, y = dailyRevenue (bar)22// Series 2: x = date, y = cumulativeRevenue (line)Expected result: Each row includes both its daily revenue and the cumulative total up to that day.
Deduplicate and Merge Duplicate Records
When APIs or queries return duplicate records, deduplicate using a Set or _.uniqBy(). For duplicate records where you need to merge data (e.g. sum order amounts for the same user appearing multiple times), use a reduce-based merge approach.
1// Deduplicate by id (keep first occurrence):2return _.uniqBy(query1.data || [], 'id');34// OR merge duplicates by summing amounts:5const rows = query1.data || [];6const merged = rows.reduce((acc, row) => {7 const existing = acc.find(r => r.user_id === row.user_id);8 if (existing) {9 // Merge: add amounts10 existing.total_amount = (existing.total_amount || 0) + Number(row.amount || 0);11 existing.order_count = (existing.order_count || 0) + 1;12 } else {13 acc.push({14 user_id: row.user_id,15 name: row.name,16 total_amount: Number(row.amount || 0),17 order_count: 118 });19 }20 return acc;21}, []);2223return merged.sort((a, b) => b.total_amount - a.total_amount);Expected result: Duplicate records are merged into single entries with combined totals.
Multi-Source Dashboard Data Transformer
For dashboards pulling from multiple queries, create a single comprehensive transformer that joins and aggregates all data sources into one object. Binding multiple dashboard components to one transformer is cleaner than binding each to individual queries with separate transformations.
1// Transformer: dashboardData2// Aggregates data from 3 queries for dashboard display3// query1: users, query2: orders, query3: products45const users = query1.data || [];6const orders = query2.data || [];7const products = query3.data || [];89// KPIs10const totalRevenue = orders.reduce((s, o) => s + Number(o.amount || 0), 0);11const activeUsers = users.filter(u => u.is_active).length;12const pendingOrders = orders.filter(o => o.status === 'pending').length;1314// Chart data: revenue by month15const revenueByMonth = _.chain(orders)16 .groupBy(o => moment(o.created_at).format('YYYY-MM'))17 .map((monthOrders, month) => ({18 month,19 revenue: monthOrders.reduce((s, o) => s + Number(o.amount || 0), 0)20 }))21 .orderBy('month')22 .value();2324// Top products by order frequency25const topProducts = _.chain(orders)26 .flatMap(o => o.items || [])27 .groupBy('product_id')28 .map((items, productId) => ({29 productId,30 count: items.length,31 product: products.find(p => p.id === productId)32 }))33 .orderBy('count', 'desc')34 .take(5)35 .value();3637return { totalRevenue, activeUsers, pendingOrders, revenueByMonth, topProducts };Expected result: A single dashboardData.value object contains all KPIs and chart-ready arrays for the entire dashboard.
Complete working example
1// Transformer: salesDashboard2// Joins orders (query1) + users (query2) + products (query3)3// Access as {{ salesDashboard.value.kpis }}, {{ salesDashboard.value.topUsers }}, etc.45const orders = query1.data || [];6const users = query2.data || [];7const products = query3.data || [];89// Build lookup maps10const userMap = _.keyBy(users, 'id');11const productMap = _.keyBy(products, 'id');1213// Orders enriched with user + product data14const enrichedOrders = orders.map(order => ({15 ...order,16 userName: userMap[order.user_id]?.name ?? 'Unknown',17 userDept: userMap[order.user_id]?.department ?? 'N/A',18 amount: Number(order.amount || 0)19}));2021// KPIs22const kpis = {23 totalRevenue: enrichedOrders.reduce((s, o) => s + o.amount, 0),24 orderCount: enrichedOrders.length,25 avgOrderValue: enrichedOrders.length > 026 ? enrichedOrders.reduce((s, o) => s + o.amount, 0) / enrichedOrders.length27 : 0,28 activeUsers: users.filter(u => u.is_active).length29};3031// Revenue by month for line chart32const revenueByMonth = _.chain(enrichedOrders)33 .groupBy(o => moment(o.created_at).format('YYYY-MM'))34 .map((monthOrders, month) => ({35 month,36 revenue: _.sumBy(monthOrders, 'amount')37 }))38 .orderBy('month')39 .value();4041// Top 10 users by revenue42const topUsers = _.chain(enrichedOrders)43 .groupBy('user_id')44 .map((userOrders, userId) => ({45 userId,46 name: userMap[userId]?.name ?? 'Unknown',47 revenue: _.sumBy(userOrders, 'amount'),48 orderCount: userOrders.length49 }))50 .orderBy('revenue', 'desc')51 .take(10)52 .value();5354return { kpis, revenueByMonth, topUsers, enrichedOrders };Common mistakes when handling Data Transformations in Retool
Why it's a problem: Using nested loops (find inside map) to join two large arrays, causing performance issues
How to avoid: Build a lookup object first: const map = _.keyBy(query2.data, 'id'). Then use map[row.id] inside .map() for O(1) lookup instead of O(n) find.
Why it's a problem: Forgetting to return a value from a reduce accumulator function
How to avoid: Every callback in .reduce() must return the accumulator. A missing 'return acc;' makes the accumulator undefined on the next iteration, causing cascading errors.
Why it's a problem: Trying to call query.trigger() inside a transformer to refresh data
How to avoid: Transformers are read-only and cannot trigger queries. If you need to re-fetch data based on transformation results, use a JS Query with query.trigger() and await.
Why it's a problem: Binding a transformer output to {{ transformer1.data }} instead of {{ transformer1.value }}
How to avoid: Transformers expose their return value as .value (e.g. transformer1.value). Using .data returns undefined. Queries use .data; transformers use .value.
Best practices
- Build lookup maps (using reduce or _.keyBy) before joining — O(n+m) lookup maps dramatically outperform O(n*m) nested loops on large datasets.
- Always add null guards at the start of every transformer: 'const data = query1.data || [];' prevents crashes when queries haven't run yet.
- Use Lodash's _.chain() for complex multi-step transformations — it enables fluent chaining without creating intermediate variables.
- Return early with a meaningful default value if input data is empty: 'if (data.length === 0) return { total: 0, items: [] };'
- Keep transformers focused on one logical output — if a transformer is getting long, split it into two transformers that each serve a specific component.
- Transformers are read-only: they cannot trigger queries, set state, or make async calls. Move any side effects to JS Queries.
- Use _.orderBy(array, ['field'], ['desc']) over .sort() for multi-field sorting — it is more readable and handles null values better.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool sales dashboard. I have three SQL queries: query1 returns orders (id, user_id, amount, status, created_at), query2 returns users (id, name, email, department, is_active), and query3 returns products (id, name, category). Write a Retool standalone transformer named 'salesDashboard' that: (1) joins orders with users using a lookup map, (2) computes KPIs (total revenue, order count, average order value), (3) groups revenue by month for a line chart, and (4) identifies the top 10 users by revenue. Use Lodash utilities available as _ and return a single object with all these datasets.
Create a transformer called 'usersWithStats' that joins query1.data (users: id, name, email, department) with query2.data (stats: user_id, total_orders, total_revenue) using a _.keyBy lookup map. The output should be the users array with totalOrders, totalRevenue, and isHighValue (true if revenue >= 1000) added to each user object. Handle missing stats with safe defaults. Access the result as {{ usersWithStats.value }}.
Frequently asked questions
How do I join data from two Retool queries in a transformer?
Build a lookup map from one query using _.keyBy(query2.data, 'id') or reduce(), then map over the other query using that map for O(1) lookups: users.map(user => ({ ...user, ...statsMap[user.id] })). This is more efficient than using .find() inside .map() which is O(n*m).
Is Lodash available in Retool transformers?
Yes. Lodash is bundled in Retool and available as the global _ variable in all transformers and JS Queries. You do not need to import it. Use _.groupBy, _.keyBy, _.sumBy, _.orderBy, _.chain, and any other Lodash utilities directly.
Can I use a Retool transformer to aggregate data from more than two queries?
Yes. A standalone transformer can reference any number of queries (query1.data, query2.data, query3.data, etc.). All referenced queries become dependencies and the transformer re-runs whenever any of them return new data. Build lookup maps from each query and join them in a single mapping operation.
Why is my .reduce() returning undefined instead of an aggregated value?
The most common cause is a missing 'return acc;' inside the reduce callback. Every path through the callback must return the accumulator. Also ensure your initial value (the second argument to reduce) matches the expected output shape — if you expect an object, start with {} not 0.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation