Author
Affiliation

Rick Gilmore

Published

May 7, 2026

Qualtrics

About

This page documents some work related to using Qualtrics.

Setup

The {qualtRics} package provides useful commands for interacting with the Qualtrics API.

The package should install as part of the set of package dependencies managed by {renv}. But you can install it directly via the R console: install.packages("qualtRics").

This site provides a helpful introduction to the package: https://cran.r-project.org/web/packages/qualtRics/vignettes/qualtRics.html

First, we load the package and store our survey credentials.

library(qualtRics)
library(ggplot2)

qualtrics_api_credentials(api_key = "<YOUR-QUALTRICS_API_KEY>", 
                          base_url = "<YOUR-QUALTRICS_BASE_URL>",
                          install = TRUE)

If the command runs properly, you should see the following message in your console:

Figure 1
WarningAvoid credential leaks

You don’t want your Qualtrics API key to get out into the wild. That’s why I don’t show mine in this example. So, do the following:

  1. Run the above commands in an R, RStudio, or Positron R console.
  2. Do not put save these credentials in a file that you check-in to version control like Git.
  3. Do not share API keys. Have each person who needs access to your survey generate their own API key.

You’ll then need to restart R for the securly stored credentials to be available to {qualtRics}. You may also wish to run library(qualtRics) so that the package commands can be called by their short names.

Viewing surveys

To view the surveys available to you1, run the following:

qualtRics::all_surveys() |>
  dplyr::arrange(desc(creationDate)) |>
  dplyr::select(-id)
# A tibble: 8 × 5
  name                                ownerId lastModified creationDate isActive
  <chr>                               <chr>   <chr>        <chr>        <lgl>   
1 bootcamp-2026                       UR_8rd… 2026-05-01T… 2026-05-01T… TRUE    
2 Sex difference survey REV 2019-11-… UR_8rd… 2021-08-26T… 2019-11-18T… TRUE    
3 Sex difference survey REV 2019-11-… UR_8rd… 2022-04-19T… 2019-11-11T… TRUE    
4 Sex differences survey REV 2019-11… UR_8rd… 2019-11-04T… 2019-11-04T… TRUE    
5 Sex differences survey OLD          UR_8rd… 2019-11-04T… 2019-10-29T… TRUE    
6 Sex difference survey               UR_8rd… 2019-10-29T… 2019-10-24T… TRUE    
7 Individual differences in visual p… UR_8rd… 2019-10-24T… 2019-10-24T… FALSE   
8 peep-II-test                        UR_8rd… 2015-09-17T… 2015-09-17T… FALSE   

You’ll see that the bootcamp-2026 survey is the first one in the list.

Important

My Qualtrics API key gives permission to see all of the surveys I have access to.

This is why you don’t want to share your personal API key.

Since this page will be open to the public, I also dropped the id column from the list above.

The Qualtrics API is probably robust to efforts from outsiders to leverage access using the id column from a specific survey, but I didn’t want to take that chance.

Note also that if the data were being downloaded to a file, and the data contained any sensitive information, I’d add the data file directory to my .gitignore file so that it didn’t get synched to GitHub.

To retrieve this survey, we run the following:

bootcamp_survey <- qualtRics::all_surveys() |>
  dplyr::filter(name == "bootcamp-2026")

The bootcamp_survey variable is a rectangular data structure called a tibble. Here are the variable names in it:

names(bootcamp_survey)
[1] "id"           "name"         "ownerId"      "lastModified" "creationDate"
[6] "isActive"    

Here are the questions:

questions <- qualtRics::survey_questions(surveyID = bootcamp_survey$id)

questions
# A tibble: 2 × 4
  qid   qname question                                                force_resp
  <chr> <chr> <chr>                                                   <lgl>     
1 QID1  Q1    How tall are you in spans (1 span = distance from tip … FALSE     
2 QID2  Q2    How enthusiastic are you about the following?           FALSE     

Let’s retrieve a copy of the results.

answers <- qualtRics::fetch_survey(surveyID = bootcamp_survey$id, 
                                   tmp_dir = "data/raw_data")

  |                                                                            
  |                                                                      |   0%
  |                                                                            
  |======================================================================| 100%
# change file name for clarity
file.copy(from = "data/raw_data/bootcamp-2026.csv", to = "data/raw_data/bootcamp-2026-silly-demo.csv")
[1] FALSE

To view the structure of the answers variable we use the str() function.

str(answers)
tibble [1 × 23] (S3: tbl_df/tbl/data.frame)
 $ StartDate            : POSIXct[1:1], format: "2026-05-01 08:31:17"
 $ EndDate              : POSIXct[1:1], format: "2026-05-01 08:31:49"
 $ Status               : chr "IP Address"
  ..- attr(*, "label")= Named chr "Response Type"
  .. ..- attr(*, "names")= chr "Status"
 $ IPAddress            : chr "165.85.38.17"
  ..- attr(*, "label")= Named chr "IP Address"
  .. ..- attr(*, "names")= chr "IPAddress"
 $ Progress             : num 100
  ..- attr(*, "label")= Named chr "Progress"
  .. ..- attr(*, "names")= chr "Progress"
 $ Duration (in seconds): num 31
  ..- attr(*, "label")= Named chr "Duration (in seconds)"
  .. ..- attr(*, "names")= chr "Duration (in seconds)"
 $ Finished             : logi TRUE
  ..- attr(*, "label")= Named chr "Finished"
  .. ..- attr(*, "names")= chr "Finished"
 $ RecordedDate         : POSIXct[1:1], format: "2026-05-01 08:31:49"
 $ ResponseId           : chr "R_5WrEY7SSM6Ruo2l"
  ..- attr(*, "label")= Named chr "Response ID"
  .. ..- attr(*, "names")= chr "ResponseId"
 $ RecipientLastName    : logi NA
  ..- attr(*, "label")= Named chr "Recipient Last Name"
  .. ..- attr(*, "names")= chr "RecipientLastName"
 $ RecipientFirstName   : logi NA
  ..- attr(*, "label")= Named chr "Recipient First Name"
  .. ..- attr(*, "names")= chr "RecipientFirstName"
 $ RecipientEmail       : logi NA
  ..- attr(*, "label")= Named chr "Recipient Email"
  .. ..- attr(*, "names")= chr "RecipientEmail"
 $ ExternalReference    : logi NA
  ..- attr(*, "label")= Named chr "External Data Reference"
  .. ..- attr(*, "names")= chr "ExternalReference"
 $ LocationLatitude     : num 39
  ..- attr(*, "label")= Named chr "Location Latitude"
  .. ..- attr(*, "names")= chr "LocationLatitude"
 $ LocationLongitude    : num -77.5
  ..- attr(*, "label")= Named chr "Location Longitude"
  .. ..- attr(*, "names")= chr "LocationLongitude"
 $ DistributionChannel  : chr "anonymous"
  ..- attr(*, "label")= Named chr "Distribution Channel"
  .. ..- attr(*, "names")= chr "DistributionChannel"
 $ UserLanguage         : chr "EN"
  ..- attr(*, "label")= Named chr "User Language"
  .. ..- attr(*, "names")= chr "UserLanguage"
 $ Q1                   : Ord.factor w/ 1 level "Click to write Choice 1": 1
  ..- attr(*, "label")= Named chr "How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? - Selected Choice"
  .. ..- attr(*, "names")= chr "Q1"
 $ Q1_1_TEXT            : num 35
  ..- attr(*, "label")= Named chr "How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? - Click to write Choice 1 - Text"
  .. ..- attr(*, "names")= chr "Q1_1_TEXT"
 $ Q2_1                 : num 40
  ..- attr(*, "label")= Named chr "How enthusiastic are you about the following? - Curling (sport)"
  .. ..- attr(*, "names")= chr "Q2_1"
 $ Q2_2                 : num 100
  ..- attr(*, "label")= Named chr "How enthusiastic are you about the following? - Banjo music"
  .. ..- attr(*, "names")= chr "Q2_2"
 $ Q2_3                 : num 100
  ..- attr(*, "label")= Named chr "How enthusiastic are you about the following? - Stargazing"
  .. ..- attr(*, "names")= chr "Q2_3"
 $ Q2_4                 : num 34
  ..- attr(*, "label")= Named chr "How enthusiastic are you about the following? - Hex stickers"
  .. ..- attr(*, "names")= chr "Q2_4"
 - attr(*, "column_map")= tibble [23 × 7] (S3: tbl_df/tbl/data.frame)
  ..$ qname      : chr [1:23] "StartDate" "EndDate" "Status" "IPAddress" ...
  ..$ description: chr [1:23] "Start Date" "End Date" "Response Type" "IP Address" ...
  ..$ main       : chr [1:23] "Start Date" "End Date" "Response Type" "IP Address" ...
  ..$ sub        : chr [1:23] "" "" "" "" ...
  ..$ ImportId   : chr [1:23] "startDate" "endDate" "status" "ipAddress" ...
  ..$ timeZone   : chr [1:23] "America/New_York" "America/New_York" NA NA ...
  ..$ choiceId   : logi [1:23] NA NA NA NA NA NA ...

There’s a lot of metadata to unpack and understand. But for our purposes here, we want a more human readable, and ‘tidier’ format.

qualtRics::extract_colmap(answers) |>
  kableExtra::kbl()
qname description main sub ImportId timeZone choiceId
StartDate Start Date Start Date startDate America/New_York NA
EndDate End Date End Date endDate America/New_York NA
Status Response Type Response Type status NA NA
IPAddress IP Address IP Address ipAddress NA NA
Progress Progress Progress progress NA NA
Duration (in seconds) Duration (in seconds) Duration (in seconds) duration NA NA
Finished Finished Finished finished NA NA
RecordedDate Recorded Date Recorded Date recordedDate America/New_York NA
ResponseId Response ID Response ID _recordId NA NA
RecipientLastName Recipient Last Name Recipient Last Name recipientLastName NA NA
RecipientFirstName Recipient First Name Recipient First Name recipientFirstName NA NA
RecipientEmail Recipient Email Recipient Email recipientEmail NA NA
ExternalReference External Data Reference External Data Reference externalDataReference NA NA
LocationLatitude Location Latitude Location Latitude locationLatitude NA NA
LocationLongitude Location Longitude Location Longitude locationLongitude NA NA
DistributionChannel Distribution Channel Distribution Channel distributionChannel NA NA
UserLanguage User Language User Language userLanguage NA NA
Q1 How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? - Selected Choice How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? Selected Choice QID1 NA NA
Q1_1_TEXT How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? - Click to write Choice 1 - Text How tall are you in spans (1 span = distance from tip of little finger to tip of thumb)? Click to write Choice 1 - Text QID1_1_TEXT NA NA
Q2_1 How enthusiastic are you about the following? - Curling (sport) How enthusiastic are you about the following? Curling (sport) QID2_1 NA NA
Q2_2 How enthusiastic are you about the following? - Banjo music How enthusiastic are you about the following? Banjo music QID2_2 NA NA
Q2_3 How enthusiastic are you about the following? - Stargazing How enthusiastic are you about the following? Stargazing QID2_3 NA NA
Q2_4 How enthusiastic are you about the following? - Hex stickers How enthusiastic are you about the following? Hex stickers QID2_4 NA NA

Let’s make a simpler dataset with just the answers to our questions.

ImportantElegant vs. quick and dirty

There are always tradeoffs. Always. Here, rather than grab the questions from the still-unfamiliar-to-me {qualtRics} package using code, I grabbed and renamed the files by hand.

If you think that you will need to run your code more than once, it’s often worth taking time to do something the more elegant and robust way. That usually means writing a function and accessing data that’s already in a structured form. The output from the qualtRics::fetch_survey() function is well-structed. I just ran out of time to learn enough about it for the purposes of this demo.

If you use Qualtrics surveys frequently and want to use R for scripted data processing, then you should definitely dig in and figure out the better way.

select_ans <- answers |>
  dplyr::select(c("EndDate", "Q1_1_TEXT", "Q2_1", "Q2_2", "Q2_3", "Q2_4")) |>
  dplyr::rename("completed" = "EndDate",
                "height_spans" = "Q1_1_TEXT",
                "enthusiasm_curling" = "Q2_1",
                "enthusiasm_banjo" = "Q2_2",
                "enthusiasm_stargazing" = "Q2_3",
                "enthusiasm_hex_stickers" = "Q2_4")

Saving cleaned copy

public_path <- "data_public"
fn <- "bootcamp-2026-silly-demo.csv"
public_fn <- file.path(public_path, fn)
readr::write_csv(select_ans, public_fn)

Visualizing

Here are some extremely simple visualizations of these data.

Note

I typically use the package::function() syntax. This helps me remember which functions are from which packages, and eliminates conflicts between functions in different packages that have the same name.

On the other hand, I usually “source” or bring into memory using the library() command the {ggplot2} package. The typical ggplot figure involves so many function calls and involves the atypical-for-R + syntax that I find this easier to write and to read.

As my children will tell you we really said to them at one point, “Make good choices.”

library(ggplot2)
select_ans |>
  ggplot() +
  geom_histogram(aes(x = height_spans)) +
  xlab("")
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Figure 2: Distribution of self-reported heights in spans.
select_ans |>
  ggplot() +
  geom_histogram(aes(x = enthusiasm_curling)) +
  xlab("")
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Figure 3: Distribution of self-reported enthusiasm for the sport of curling.
select_ans |>
  ggplot() +
  geom_histogram(aes(x = enthusiasm_banjo)) +
  xlab("")
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Figure 4: Distribution of self-reported enthusiasm for banjo music.
select_ans |>
  ggplot() +
  geom_histogram(aes(x = enthusiasm_stargazing)) +
  xlab("")
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Figure 5: Distribution of self-reported enthusiasm for stargazing.
select_ans |>
  ggplot() +
  geom_histogram(aes(x = enthusiasm_hex_stickers)) +
  xlab("")
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Figure 6: Distribution of self-reported enthusiasm for hex stickers.

Footnotes

  1. In descending order by the date the survey was created.↩︎