Tidyverse-First Data Fabrication
Overview
Helps you imagine your data before you collect it. Builds a data frame one column at a time, with hierarchical levels, cross-classified levels, potential outcomes, and draws with a target intra-cluster correlation, so that a research design can be simulated and diagnosed before any data exist. Version 2.0 is a rewrite of the same public API on 'dplyr', 'tibble', 'purrr', and 'rlang'.
fabricatr 2.0.0 is a rewrite of the package on dplyr, tibble, purrr, and rlang, with the same exported functions as 1.0.2. The vignette vignette("fabricatr2.0") walks through every difference; the list below is the summary.
Breaking changes
fabricate()returns a tibble where 1.0.2 returned a plain data frame. The columns, their order, their types, and their values are unchanged, and a tibble is a data frame, so anything that accepts one still works. Two things differ for a caller:identical()against a plain data frame is nowFALSE, anddf[, "Y"]returns a one-column tibble rather than dropping to a vector.df$Yanddf[["Y"]]are unchanged.Nis supplied by name.fabricate(100, Y = rnorm(N))was read asN = 100in 1.x and is now an error whose message says to writefabricate(N = 100, Y = rnorm(N)).- Every expression passed to
fabricate()or to a level needs a name. An unnamed one,fabricate(N = 5, rnorm(N)), is an error naming its position; 1.x failed on it with an internal indexing error. link_levels()islink_levels(N, .by, ..., rho = 0, sigma = NULL).rhoandsigmasit behind the dots, so they must be named and a column calledrorsis no longer partially matched into them. In 1.x they were arguments ofjoin_using().draw_normal_icc()anddraw_binary_icc()takeclustersfirst:draw_normal_icc(clusters, ICC, mean, sd, sd_between, total_sd, N)anddraw_binary_icc(clusters, prob, ICC, N). Positional calls written for 1.x's order (mean, N, clusters, ...andprob, N, clusters, ICC) need names.draw_ordered()requiresbreaks; 1.x defaulted it toc(-1, 0, 1).draw_likert(breaks = )reads interior cut-points, asdraw_ordered(breaks = )does, sodraw_likert(x, breaks = c(-1, 0, 1))gives four categories where 1.0.2 gave two. 1.0.2 handedbreakstocut(), which reads the vector as the full set of bin boundaries and sends every value outside it toNA: on 200 draws fromrnorm()the same call returned two categories and 53NA.draw_likert()is a convenience wrapper arounddraw_ordered(), and in 1.0.2 the two read the same argument name in two different ways, sincedraw_ordered(x, breaks = c(-1, 0, 1))gives four categories in 1.0.2 and in 2.0 alike. To ask for bin boundaries, say so:draw_ordered(x, breaks = c(-1, 0, 1), strict = TRUE)returns 1.0.2's two categories and its 53NA.draw_likert(min = , max = , bins = )puts values outside[min, max]in the outermost bins rather than returningNAfor them.minandmaxdeclare the range the latent variable is cut on, not a filter, and 1.0.2'sNAwas missing data in a Likert item that nothing reported:draw_likert(x, min = -3, max = 3, bins = 5)on 200 draws fromrnorm()returned one. A value exactly equal tominwasNAtoo, becausecut()leaves its lowest boundary open. The same recipe recovers 1.0.2 exactly:draw_ordered(x, breaks = seq(min, max, length.out = bins + 1), strict = TRUE).draw_likert()returns integer codes where 1.0.2 returned doubles, which it got by passingcut()'s factor throughas.numeric(). The codes are the same numbers, so onlyidentical()andtypeof()see the difference.draw_ordered()returned integer codes in 1.0.2 and still does.draw_categorical()dropslatentandlink, which 1.x accepted but rejected for any link other than identity.
Show all changes in 2.0.0Hide the rest of the changes in 2.0.0
total_sdindraw_normal_icc()is a parameter of the draw rather than a rescaling of the finished vector, so the realisedsd()varies from draw to draw as any sample statistic does. The same call gives different numbers than 1.x (fabricatr#133).modify_level()named after a level above the current one,regions = modify_level(z = rnorm(N))after acitieslevel, evaluates once per region as 1.x does, and then keeps the cities. 1.x returned the regions frame alone, dropping the nested rows.reveal_outcomes()keeps the type of the potential outcomes it reads. A factor reveals as a factor with its levels and their order intact, and aDateas aDate. 1.x selected the revealed value by matrix-indexing, which rendered both as character: an outcome declaredlo < mid < hicame back as character, so re-factoring it ordered the levels alphabetically ashi < lo < midand a model fitted on it used the wrong baseline in silence. Where the conditions do not agree on one set of factor levels there are none to keep, so the result is character and a warning says so. This is a deliberate break with 1.0.2.potential_outcomes()names its columns exactly as<outcome><sep><var><sep><value>builds them, as its documentation always said, even where the result is not a syntactic R name. 1.0.2 passed the columns throughas.data.frame(), whose name check rewrote any character R does not allow in a bare name to.: a negative condition'sY_Z_-1came out asY_Z_.1, a condition"b c"asY_Z_b.c, and every column undersep = "-"asY.Z.0. Code that read one of those rewritten names, say an inquirymean(Y_Z_1 - Y_Z_.1), must now write the name as built, `mean(Y_Z_1 -Y_Z_-1)or.data[["Y_Z_-1"]]. A design whose conditions andsepare all syntactic, such as the defaultZ = c(0, 1)with"_"or character conditions like"control"and"treat", names its columns as before. The rewritten names were also the reasonreveal_outcomes()` could not reveal those designs; see the fix below.
Reproducing an old seed
fabricatr does not promise that a given seed produces the same fabricated data in 2.0 as it did in 1.x, and two things here land at a different point in the random number stream. The distribution each one draws from is unchanged; only the position moves. This is deliberate: fabricatr invents data to stand in for data you do not have, so which particular draw you got is not a finding. Contrast randomizr, where the draw assigns real units to real treatments and is therefore part of the result, and whose stream does not move across versions. What 2.0 does promise is that it gives the same numbers as itself on every machine, which 1.x could not.
If a saved analysis draws its data through fabricate() under a stored seed, expect its numbers to move; re-run it and store the new ones.
link_levels()withrhoorsigmatakes one draw path,chol()on the correlation matrix, on every machine. 1.x usedmvnfast::rmvn()when that package was installed, and passed it a core count, so its numbers depended on both. Withoutmvnfastinstalled, 1.x and 2.0 agree exactly.fabricate(data = df, ...)no longer advances the stream before evaluating your first column. 1.x named the workspace slot it putdfinto withsample.int(.Machine$integer.max, 1), which costs exactly two uniforms, so every 1.x call starting from data began two draws in.
Deprecations
Each of these is accepted and warns once per call site with the call to write instead.
add_level(..., nest = FALSE)isdeclare_level(...), andnest = TRUEisadd_level(...).cross_levels(by = join_using(A, B))andlink_levels(by = join_using(A, B))are.by = c("A", "B"). Arhoinsidejoin_using()is carried through tolink_levels(rho = ). R will not partial-match a suppliedby =to a formal named.by, so.byis left unfilled and R binds the first *unnamed* argument to it positionally:cross_levels(by = join_using(A, B), potential_outcomes(Y ~ Z))put the potential outcomes in.by, where the shim discarded them without a warning and the design ran on without its potential outcomes. The displaced argument is now put back where the author wrote it, so the two spellings of such a call return the same data.modify_level(..., by = "g")ismodify_level(..., .by = "g").draw_ordered(break_labels = )anddraw_categorical(category_labels = )arelabels =.join_using()andrecycle()are kept so that 1.x code runs; neither is needed in new code.
New
declare_level()builds an independent level forcross_levels()andlink_levels(), replacingadd_level(nest = FALSE).import_level()brings an existing data frame into afabricate()call as a level of its own, so two real data sets can be crossed or linked (fabricatr#165). The level's ID is the frame's own key: a column named after the level is used as it stands, the way a join matches on a shared name,.idnames one that is called something else, and a frame with no key of its own gets numbered rows. An imported key keeps its own type and values, so it still matches the data it came from, and a key with a repeated or missing value is refused.add_level(data = df), the spelling the issue was filed on and one that has never imported anything in either version, now fails with a message namingimport_level(). A column calledNis refused, becauseNis the row count in every expression a level evaluates and the column would leave the frame in silence;fabricate(data = )evaluates nothing at a level and still keeps it.fabricate_with_dots()is exported. It takes an already-captured list of quosures and builds whatfabricate()would have built from the same expressions, which is what a package whose own verbs capture their arguments needs: splicing captures back through!!!renders them as formulas andfabricate()captures those again, one level too deep. It existed as an internal in 2.0's development and DeclareDesign reached it with:::, which made the boundary between the two packages depend on nothing but the two of them sharing a maintainer, sinceR CMD checksuppresses the unexported-object NOTE in that case alone.n()inside any declaration is the number of rows the level is building, the same countNnames, as a function call that cannot be confused with a parameter of the design.- Level calls and ordinary columns can be mixed in one
fabricate()call. 1.x required all of the arguments to be level calls or none of them. modify_level(.by = )groups the rows in hand by a column before evaluating, on flat data as well as on levels.ID_label = NAsuppresses the unit ID column, leaving the rows it would have numbered. 1.x accepted the argument and then failed with an indexing error, so there was no way to ask for a frame without one.draw_ordered(),draw_likert(), anddraw_categorical()takelabels.draw_ordered()anddraw_likert()return an ordered factor when it is given, anddraw_categorical()an unordered one, as in 1.x.draw_likert()reports the number of categories a manualbreaksvector produces whenlabelsdoes not match it, rather than failing insidecut().draw_normal_icc()accepts any two ofICC,sd,sd_between, andtotal_sd, and the fullICCrange including 0 and 1 (fabricatr#149).draw_count()takesdispersionfor overdispersed counts. The default 0 is the Poisson draw it has always made, on the same random number stream. A positive value draws from the negative binomial with the same mean and variancemean + dispersion * mean^2, the parameterization of Stata'snbregand the reciprocal ofMASS::glm.nb()'stheta. The link, the latent scale, andquantile_ywork as before, so an overdispersed count can be correlated with another variable throughcorrelate().draw_normal_ar()draws an AR(1) process inside each unit of a panel, the serially correlated errors that make unclustered standard errors wrong in difference-in-differences. Each unit's series starts at its stationary distribution, so every period has the same variance, and a gap intimedecays the correlation asrhoraised to the gap, so an unbalanced panel needs no special handling.timemay be the character ID thatcross_levels()gives a level, or aDate, read in days.sdis the standard deviation of the process in each period, not of each period's new shock as instats::arima.sim(), so changingrholeaves the variance where it was. Binary and count outcomes with the same persistence come from passing the draw on aslatentorquantile_y.correlate()works with base R random number generators such asrnorm(), not only thedraw_*family.link_levels()validatessigma: wrong dimensions, entries outside[-1, 1], asymmetry, and a matrix that is not positive semi-definite are errors rather than a silently uncorrelated draw.
Fixes against 1.0.2
- A matrix-valued column is split into
X.1,X.2, ... as it is stored, so a later expression can readX.1(fabricatr#188). draw_binary_icc()pairs each cluster with its own probability whenprobhas one value per unit, and errors when a cluster's probabilities disagree (fabricatr#189).fabricate(N = 2.5)andN = 0are errors, as in 1.0.2, rather than a silently truncated row count.fabricate(data = df, N = 10)is an error: the data fix the number of rows.draw_quantile()returns an unordered factor, as 1.x does, so a model formula picks treatment contrasts rather than polynomial ones.draw_ordered()numbers its categories from 1 whateverbreakslooks like. An infinite endpoint bounds the scale rather than cutting it, soc(-Inf, 0, Inf)gives the same two categories as0, andstrict = TRUEdrops the open end categories instead of leaving them empty and unreachable. 1.0.2 reads the lower end the same way but applies the same test to a trailingInf, soc(-1, 0, Inf)came back 0-based; only the lower end decides, sincefindInterval()returns 0 for a value below the first break and nothing else.labelsnow takes one label per category that can occur, which islength(breaks) + 1for interior cut-points and one fewer for each infinite endpoint.draw_likert(labels = )attaches each label to its own category. 1.0.2 built the factor withlevels = unique(x_ret), the order the categories happen to appear in the data rather than their sorted order, so each label landed on whichever category turned up first:draw_likert(c(2.5, 1.5, 0, -1.5, -2.5), min = -3, max = 3, bins = 5, labels = c("SD", "D", "N", "A", "SA"))labelled the highest valueSDand the lowestSA, inverting the scale in silence. When fewer thanbinscategories occurred it failed outright, withinvalid 'labels'; length 5 should be 1 or 3raised from insidefactor().resample_data(unique_labels = TRUE)builds labels matching 1.x column for column.- ID columns are zero-padded character strings at every level, padded to the number of units at that level, as in 1.x.
- An
NAingivenpropagates to the result ofcorrelate()rather than being absorbed into it.rank()puts anNAlast by default, so 1.0.2 gave the missing value the highest rank and drew a correspondingly extreme value to match: the returned vector held noNAanywhere and the missingness read as a real observation. The observed values are now ranked among themselves and eachNAcomes back as anNA. - Argument validation names the argument and the function rather than failing inside a comparison. An
NAsupplied asrhotocorrelate(), asproborNtodraw_categorical(), asICCtodraw_binary_icc(), or asmin,max, orbinstodraw_likert()reached anif()stillNAand raised R's ownmissing value where TRUE/FALSE needed, which names neither. A fractionaltypeinsplit_quantile()anddraw_quantile()is an error rather thannumber of intervals and length of 'labels' differraised from insidecut(). modify_level(.by = )names the argument when it is not a column name..byis an ordinary argument, so a bare column name evaluates to the column and arrives as a vector, while the 1.x spellingby =is captured unevaluated and does take a bare name: the two differ, andmodify_level(m = mean(Y), .by = g)reached the subscript and failed there withno such index at level 1. A column name that is not in view gaveargument 1 is not a vector, two names gavesubscript out of bounds, and a number silently grouped by whichever column came first.cross_levels()andlink_levels()already named their function and their.by.- An unrecognised
linkindraw_binary()reportsdraw_binary(). It delegates todraw_binomial(), which validated the name under its own label, so the error named a function the caller had
The NEWS section is longer than shown here.
From NEWS.md in the release
Install
Health
- OK13 OK2026-10-25
- NOTE11 OK, 2 NOTE2026-07-16
- OK14 OK2026-03-10
Documentation
- Help pages
- 28
- Examples that run
- 100%28 run in a plain check (1 of them under a condition)
- Exported functions with examples
- 100%
- Documented parameters
- 98%
- Return-value docs
- 96%
- Help pages citing references
- 0%
Downloads
Repository
- PRs merged (all time)
- 85%
- Median days to close, 50 most recently updated closed issues
- 102
- Median days to close, 50 most recently updated closed PRs
- 1
Stars over time
Forks over time
Releases over time
Issues over time
PRs over time
Commits over time
Commit identities over time
10 development-tooling and community-health practices detected across 6 families in the upstream repository
Checks run against github.com/declaredesign/fabricatr on 2026-10-21.
The R CMD check workflow runs on Linux, macOS and Windows, and on R-devel
GitHub Pages site last deployed 2018-05-28
Show all practicesHide 2 practices
This repository shows signs of AI coding tools.
- Claude Codefirst seen 2026-08-27Pull request it wrote: opened by a project memberCommits: at least 5 crediting it in the message
- Claude Opus 55 commits · Aug to Sep 2026
Dependencies
Installing fabricatr adds 16 packages (10 with compiled code, 5.5 MB of source tarballs). Heaviest: vctrs 1.0 MB, dplyr 902 KB, rlang 766 KB.
Code & Tests
People & History
Previously maintained by Graeme Blair (0.2.0 to 1.0.2).
Pick two releases to compare their code metrics.
- 2.0.0Latest2026-10-21 · current release · diff ↗ ··
added 4 exports
declare_level, draw_normal_ar, fabricate_with_dots, import_level22 hours in CRAN's queue
Listed in CRAN's incoming queue from 2026-10-20 17:40 to 2026-10-21 15:47 UTC, in 6 readings. - RR 4.6.0 released · 2026-04-24
- RR 4.5.0 released · 2025-04-11
- RR 4.4.0 released · 2024-04-24
- 1.0.22024-01-16 · diff ↗
- RR 4.3.0 released · 2023-04-21
- 1.0.02022-06-29 · diff ↗
- RR 4.2.0 released · 2022-04-22
- 0.16.0
- 0.14.02022-02-10 · diff ↗
- RR 4.1.0 released · 2021-05-18
- 0.12.0
- RR 4.0.0 released · 2020-04-24
- 0.10.02019-09-04 · diff ↗
- RR 3.6.0 released · 2019-04-26
- 0.8.02019-03-12 · diff ↗
Package metadata
- First published
- 2018-01-25
- Total releases
- 11 / 8 yrs
- License
- MIT + file LICENSE OSI
- Minimum R
- ≥ 4.1.0
- Download size
- 167 KB
Cite
Cite this package
Run in R for the authors' preferred citation:
citation("fabricatr")This package has no citation file, so citation() returns this entry built from DESCRIPTION.
Cite the R Observatory
For a number measured here: a download total, a coverage figure, an archival date.
From data release v2026-10-25, which the citation names so these numbers can be found later. More on citing and the projects behind them.