Creating interactive dashboards in Stata with dash

Billy Buchanan

Senior Research Scientist

SAG Corporation

https://wbuchanan.github.io/stataConference2026

  • dash is intended as a drop-in replacement for twoway, with some limitations
  • Filters, brushing & linking, Bootstrap.js, several schemes, dozens of graph types, and more supported
  • Output a standalone HTML file with everything needed*
  • Developed using an AI coding assistant
  • ALL AI AGENTS LIE

Motivation

  • Several attendees at previous Stata Conferences have requested dashboard tooling
  • Last year, a few attendees asked about AI coding assistance and/or integration
  • Combining both in a single project seemed like a unique and interesting challenge

Creating dash

Design Decisions

  • Building directly in Stata would be less useful to more users
  • Java vs Python
  • Streamlit vs Bokeh
  • Mirror twoway's syntax and behavior as close as possible
  • Provide some additional features common to dashboards
  • Identify which AI Coding assistant would provide the best results

A very brief history of language models

  • 2014 - seq2seq: Allows variable length input to variable length output (Sutskever, Vinyals & Le)
  • 2015 - attention: Provides a way to encode context in addition to semantics of the tokens (Bahdanau, Cho & Bengio)
  • 2017 - Transformers: Adds positional encoding to attention and computational efficiencies (Vaswani et al.)
  • The problem I needed to solve was translating Stata to Python

How and What to translate

  • Data could be translated using jsonio or the Stata Function Interface
  • Parsing Stata commands would be easier in Stata
  • Not everything needed a literal translation (e.g., if/in expressions)
  • The biggest translation task would be the graph type declarations and their options
  • The first step was to develop and execute the plan, followed by creating and executing additional plans

Examples

nlsw88: the code


sysuse nlsw88, clear
dash clear
format wage                                       %9.2f
format ttl_exp tenure                             %9.1f
format age grade hours                            %9.0f
format industry occupation union collgrad race    %9.0f
dash (scatter wage ttl_exp, msize(small))             ///
     (lfit    wage ttl_exp),                          ///
    name(g1) title("Hourly wage by total work experience") ///
    xtitle("Total work experience (years)")           ///
    ytitle("Hourly wage (USD)")
dash (scatter wage tenure, msize(small))              ///
     (lfit    wage tenure),                           ///
    name(g2) title("Hourly wage by tenure in current job") ///
    xtitle("Job tenure (years)")                      ///
    ytitle("Hourly wage (USD)")
dash (scatter wage grade, msize(small) jitter(3) jitterseed(1988)) ///
     (lfitci  wage grade),                            ///
    name(g3) title("Hourly wage by years of schooling")   ///
    xtitle("Highest grade completed")                 ///
    ytitle("Hourly wage (USD)")
dash (histogram wage, bin(40) density)                ///
     (kdensity  wage),                                ///
    name(g4) title("Distribution of hourly wages")        ///
    xtitle("Hourly wage (USD)") ytitle("Density")
dash (kdensity wage if union == 1)                    ///
     (kdensity wage if union == 0),                   ///
    name(g5) title("Wage density: union and nonunion workers") ///
    xtitle("Hourly wage (USD)") ytitle("Density")     ///
    legend(label(1 "Union") label(2 "Nonunion"))
dash kpi count(wage),   name(k1) title("Women")             fmt(%9.0fc) ///
    subtitle("in the current filters")   update(both)
dash kpi mean(wage),    name(k2) title("Mean hourly wage")  fmt(%9.2f)  ///
    subtitle("USD, 1988")                update(both)
dash kpi median(wage),  name(k3) title("Median hourly wage") fmt(%9.2f) ///
    subtitle("USD, 1988")                update(filter)
dash kpi p90(wage),     name(k4) title("90th percentile")   fmt(%9.2f)  ///
    subtitle("of the hourly wage")       update(filter)
dash kpi iqr(wage),     name(k5) title("Interquartile range") fmt(%9.2f) ///
    subtitle("of the hourly wage, USD") update(filter)
dash kpi count(wage),   name(k6) title("Selected")          fmt(%9.0fc) ///
    subtitle("box- or lasso-selected women") update(brush)
dash build,                                                          ///
    saving("dashboards/nlsw88-filters.html") replace nobrowser        ///
    title("Young women in the labour force, 1988")                   ///
    subtitle("NLSW 1988 extract, 2,246 women aged 34 to 46")         ///
    layout(bs)                                                       ///
    tabs(Overview: k1 k2 k6 g1 g2 g3 / Distributions: k3 k4 k5 g4 g5) ///
    filterposition(left)                                             ///
    filter(industry, type(dropdown) label("Industry")  exclude(g4))  ///
    filter(age,      type(range)    label("Age")       exclude(g4))  ///
    filter(hours,    type(slider)   label("Weekly hours, at least") exclude(g4)) ///
    link(g1 g2 g3)
				

sp500: the code


sysuse sp500, clear
dash clear
format close open high low change   %9.2f
format volume                       %12.0fc
recast double close open high low change
foreach v of varlist close open high low change {
    quietly replace `v' = round(`v' * 100) / 100
}
dash (rarea low high date, fcolor(gs13) lcolor(gs13))     ///
     (line  close date),                                  ///
    name(g1) title("Daily range and close")                   ///
    ytitle("Index level")                                 ///
    legend(label(1 "Low to high") label(2 "Close"))       ///
    hover(volume)
dash (area volume date, yaxis(2) fcolor(ltblue%60) lcolor(ltblue)) ///
     (line close date),                                   ///
    name(g2) title("Close and trading volume")                ///
    ytitle("Index level") ytitle2("Volume (thousands)")
dash spike change date,                                   ///
    name(g3) title("Daily change in the close")               ///
    ytitle("Change (index points)") yline(0)
dash kpi min(date),    name(k1) title("First trading day") ///
    subtitle("in the current range")
dash kpi max(date),    name(k2) title("Last trading day")  ///
    subtitle("in the current range")
dash kpi max(close),   name(k3) title("Highest close")  fmt(%9.2f)   ///
    subtitle("index level")
dash kpi mean(volume), name(k4) title("Mean daily volume") fmt(%12.0fc) ///
    subtitle("thousands of shares")
dash build,                                                          ///
    saving("dashboards/sp500-timeseries.html") replace nobrowser      ///
    title("S&P 500 in 2001")                                         ///
    subtitle("248 trading days; narrow the dates, then download what remains") ///
    layout(bs: k1=3 k2=3 k3=3 k4=3 / g1=12 / g2=7 g3=5)               ///
    filter(date,   type(range)  label("Trading days"))               ///
    filter(volume, type(slider) label("Daily volume, at least"))     ///
    csv
				

lifeexp: the code


sysuse lifeexp, clear
dash clear
format lexp        %9.0f
format gnppc       %12.0fc
format popgrowth   %9.2f
generate double lngnppc = ln(gnppc)
label variable lngnppc "log GNP per capita"
format lngnppc %9.2f
egen byte rank_lexp  = rank(lexp),   unique
egen byte rank_gnppc = rank(-gnppc), unique
generate str28 label_country = country if rank_lexp <= 6 | rank_gnppc <= 6
label variable label_country "Country"
dash (scatter lexp gnppc, colorvar(region) colordiscrete         ///
                          mlabel(label_country) mlabsize(small) mlabposition(3)), ///
    name(g1) title("Life expectancy and income, 1998")          ///
    xscale(log)                                                  ///
    xtitle("GNP per capita (USD, log scale)")                    ///
    ytitle("Life expectancy at birth (years)")                   ///
    hover(country)
dash (scatter lexp lngnppc, colorvar(region) colordiscrete)      ///
     (lfitci  lexp lngnppc),                                     ///
    name(g2) title("Linear in log income, with its 95% band")   ///
    xtitle("log GNP per capita")                                 ///
    ytitle("Life expectancy at birth (years)")                   ///
    hover(country)
dash kpi count(country),  name(k1) title("Countries")            fmt(%9.0f) ///
    subtitle("in the current filters")   update(both)
dash kpi mean(lexp),      name(k2) title("Mean life expectancy") fmt(%9.1f) ///
    subtitle("years at birth")           update(both)
dash kpi median(gnppc),   name(k3) title("Median GNP per capita") fmt(%12.0fc) ///
    subtitle("USD")                      update(both)
dash kpi mean(popgrowth), name(k4) title("Mean population growth") fmt(%9.2f) ///
    subtitle("percent per year")         update(both)
dash build,                                                          ///
    saving("dashboards/lifeexp-encoding.html") replace nobrowser      ///
    title("Life expectancy and national income, 1998")               ///
    subtitle("68 countries, drawn under Stata's stcolor scheme")     ///
    scheme(stcolor)                                                  ///
    layout(bs: k1=3 k2=3 k3=3 k4=3 / g1=7 g2=5)                       ///
    filter(region, type(dropdown) label("Region"))                   ///
    filter(lexp,   type(range)    label("Life expectancy"))          ///
    link(g1 g2)
				

census: the code


sysuse census, clear
dash clear
format region  %9.0f
format medage  %9.2f
format pop     %12.0fc
generate double drate  = death    / pop * 1000
generate double mrate  = marriage / pop * 1000
generate double dvrate = divorce  / pop * 1000
generate double urban  = popurban / pop * 100
generate double old    = pop65p   / pop * 100
generate double young  = (poplt5 + pop5_17) / pop * 100
label variable drate  "Deaths per 1,000 residents"
label variable mrate  "Marriages per 1,000 residents"
label variable dvrate "Divorces per 1,000 residents"
label variable urban  "Urban share of the population (%)"
label variable old    "Share aged 65 and older (%)"
label variable young  "Share under 18 (%)"
format drate mrate dvrate urban old young %9.1f
dash (scatter dvrate mrate, msize(medium)),                          ///
    name(g1) title("Divorces and marriages")                          ///
    xscale(log) xsize(10) ysize(4)                                   ///
    xtitle("Marriages per 1,000 residents (log scale)")              ///
    ytitle("Divorces per 1,000 residents")                           ///
    hover(state)
dash (scatter drate old, msize(medium)),                             ///
    name(g2) title("Deaths and the share aged 65 and older")          ///
    xsize(10) ysize(4)                                               ///
    xtitle("Share aged 65 and older (%)")                            ///
    ytitle("Deaths per 1,000 residents")                             ///
    hover(state)
dash (scatter young medage, msize(medium)),                          ///
    name(g3) title("Share under 18 and median age")                   ///
    xsize(10) ysize(4)                                               ///
    xtitle("Median age (years)")                                     ///
    ytitle("Share under 18 (%)")                                     ///
    hover(state)
dash (scatter urban pop, msize(medium)                               ///
                         mlabel(state2) mlabsize(small) mlabposition(3)), ///
    name(g4) title("Urban share and population")                      ///
    xscale(log) xsize(10) ysize(4)                                   ///
    xtitle("Population (log scale)")                                 ///
    ytitle("Urban share of the population (%)")                      ///
    hover(state)
dash (scatter drate medage, msize(medium))                           ///
     (lfit    drate medage),                                         ///
    name(g5) title("Deaths and median age, by census region")         ///
    by(region, cols(2)) xlabel(#5) ylabel(#5) xsize(10) ysize(4)      ///
    xtitle("Median age (years)")                                     ///
    ytitle("Deaths per 1,000 residents")                             ///
    hover(state)
dash (scatter medage old, msize(medium))                             ///
     (lowess  medage old),                                           ///
    name(g6) title("Median age and the share aged 65 and older")      ///
    xtitle("Share aged 65 and older (%)")                            ///
    ytitle("Median age (years)")                                     ///
    hover(state)
dash (scatter medage young, msize(medium))                           ///
     (qfitci  medage young),                                         ///
    name(g7) title("Median age and the share under 18")               ///
    xtitle("Share under 18 (%)")                                     ///
    ytitle("Median age (years)")                                     ///
    hover(state)
dash kpi count(state), name(k1) title("States")            fmt(%9.0f) ///
    subtitle("in the current filters")   update(both)
dash kpi mean(medage), name(k2) title("Mean median age")   fmt(%9.1f) ///
    subtitle("years")                    update(both)
dash build,                                                          ///
    saving("dashboards/census-tabs.html") replace nobrowser           ///
    title("The 50 states in 1980")                                   ///
    subtitle("Census extract, drawn under Stata's economist scheme") ///
    scheme(economist)                                                ///
    layout(bs)                                                       ///
    tabs(Rates: g1 g2 g3 g4 / By region: g5 / Age structure: k1 k2 g6 g7) ///
    filter(region, type(dropdown) label("Census region"))            ///
    filter(medage, type(range)    label("Median age"))               ///
    link(g1 g2 g3 g4 g5)
				

Wrapping Up

Known issues and limitations

  • Filters and brushes affect data but not computed layers (e.g., fitted lines)
  • Four plot types are not currently implemented: contour, contourline, rpcap, and rpspike
  • Not every option on every graph is fully supported
  • Non-supported options should not prevent using the command, but limit control of the aesthetics
  • There will be inaccuracies in the helpfile during development and until there is time to thoroughly verify what is supported

Lessons Learned

  • ALL AI CODING ASSISTANTS LIE
  • ALL AI CODING ASSISTANTS WILL TAKE THE SHORTEST PATH POSSIBLE TO "COMPLETE" A TASK
  • Task complexity and accurate performance are inversely related
  • The size of the scope of a task is also inversely related to accurate performance
  • Planning work for an AI coding assistant is more similar to programming than planning work for a human
  • The quality of the work varies significantly across the AI models
  • The quality also varies significantly across the companies developing the AI models

Recommendations for using AI coding assistants with Stata

  1. Plan for the AI coding assistant to be deceptive
  2. Take advantage of retrieval augmented generation
  3. Use a system prompt, or file, to define behavioral patterns you want the tool to exhibit
  4. Spend the majority of your time making sure you have a well-developed plan
  5. Make sure each discrete task in the plan is tightly scoped and that tasks are sequenced in a logical order
  6. Use the most advanced model you can to build the planning documents
  7. Use less advanced models to implement the plans and keep costs manageable
  8. Use the most capable model to check the work of less advanced models
  9. Use models from a competitor to check the work of your preferred coding assistant
  10. Prepare to spend a significant amount of your time testing code and providing additional clarifying instructions
  11. Assume you will need several iterations of plan development and execution for complex tasks to complete them