How to use the Tableau Public API
Last week, I shared two projects built using the Tableau Public API (developed by Will Sutton ). In this article, I'll walk you through the first project: building a VOTD (Viz of the Day) dashboard with Python and Tableau.
⚠️ P.S. I used Cursor AI as a coding assistant to help draft parts of this script, it’s a great tool for getting things moving quickly without getting stuck on boilerplate code. My main focus was on building and designing the Tableau dashboard, so using Cursor helped me spend less time writing repetitive code and more time visualizing the data.
The script is also fully customizable, so feel free to tweak the image size, the number of VOTDs, or any of the saved fields to match your specific use case or dashboard style.
This script does three main things:
Table of Content
Glossary
1. Setup and Imports
Before we start doing anything, we need to bring in the tools (called libraries) that help Python do certain tasks. These cover:
1.1. Imports
import os, requests, pandas as pd, time, io, json, html
from PIL import Image
from datetime import datetime, timedelta
import concurrent.futures
1.2. Libraries
pip install requests pandas pillow
python --version
2. Fetching the VOTD Data
This function acts like a robot that visits Tableau’s Viz of the Day page again and again, collecting up to 500 dashboards. Each time it collects a few, it saves them in a list. It keeps repeating this process until it hits the 500 mark, or there are no more dashboards to grab.
def fetch_all_votd(limit=500):
# Fetch up to 500 VOTDs from the API.
page = 0
page_size = 12
all_votds = []
while len(all_votds) < limit:
url = f"https://public.tableau.com/public/apis/bff/discover/v1/vizzes/viz-of-the-day?page={page}&limit={page_size}"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
# Extract vizzes from the response
if isinstance(data, dict) and 'vizzes' in data:
vizzes = data['vizzes']
elif isinstance(data, list):
vizzes = data
else:
print(f"Unexpected data format on page {page}")
break
if not vizzes:
print(f"No more vizzes found on page {page}")
break
# Add each viz to our collection
for viz in vizzes:
if len(all_votds) >= limit:
break
all_votds.append(viz)
print(f"Fetched page {page}, total so far: {len(all_votds)}")
page += 1
except requests.RequestException as e:
print(f"Network or HTTP error on page {page}: {e}")
break
except ValueError as e:
print(f"Invalid JSON response on page {page}: {e}")
break
except Exception as e:
print(f"Unexpected error on page {page}: {e}")
break
print(f"Total VOTDs fetched: {len(all_votds)}")
return all_votds
During data fetching, progress updates like page numbers and API responses will be printed in the terminal to help track what’s being loaded and identify issues early.
3. Cleaning Up Text
Sometimes the text we get from the internet includes weird codes like & instead of &. This function cleans up those messy parts so everything looks nice and readable, just like correcting spelling and punctuation before putting text on a slide.
def clean_text(text):
if not isinstance(text, str):
return text
# Decode HTML entities
text = html.unescape(text)
# Remove any remaining HTML tags
text = text.replace('&', '&')
text = text.replace(''', "'")
text = text.replace('"', '"')
return text.strip()
4. Image Extraction Utilities
4.1. Getting the image URL
This finds the best link to the image preview of a dashboard. It first looks for the high-quality version (called ‘curated’), and if that’s not available, it tries the regular one. If both are missing, it builds a custom one using other data.
def get_image_url(viz):
Extract image URL from viz data.
# Try curated image first
if viz.get("curatedImageUrl"):
return viz["curatedImageUrl"]
# Then try regular image
if viz.get("imageUrl"):
return viz["imageUrl"]
# Finally, construct from workbook and view URLs
if viz.get("workbookRepoUrl") and viz.get("defaultViewRepoUrl"):
view_name = viz["defaultViewRepoUrl"].split('/')[-1]
return f"https://public.tableau.com/views/{viz['workbookRepoUrl']}/{view_name}.png?:display_static_image=y&:showVizHome=n"
return None
4.2. Getting the viz URL
This finds the actual web link to view the dashboard on Tableau Public. If there’s no direct link, it tries to build one from scratch.
def get_viz_link(viz):
"""Extract viz link from viz data."""
if viz.get("publicUrl"):
return viz["publicUrl"]
if viz.get("workbookRepoUrl") and viz.get("defaultViewRepoUrl"):
view_name = viz["defaultViewRepoUrl"].split('/')[-1]
return f"https://public.tableau.com/views/{viz['workbookRepoUrl']}/{view_name}"
4.3. Image Folder Maintenance
Before we download new images, this function makes sure the image folder is empty, like cleaning out a drawer before filling it with new files.
def clear_images_folder(images_dir):
# Clear all PNG files from the images directory.
if os.path.exists(images_dir):
files = glob.glob(os.path.join(images_dir, '*.png'))
for f in files:
os.remove(f)
else:
os.makedirs(images_dir, exist_ok=True)
4.4. Resize Downloaded Images
Each dashboard image comes in different sizes. This function resizes them so they all fit neatly on the same canvas (1600x900 pixels), just like cropping photos to the same frame size. If needed, it adds transparent padding so nothing gets stretched or squished.
def resize_image(image_data, target_size=(1600, 900)):
"""Resize image to target dimensions while maintaining aspect ratio."""
try:
# Open image from bytes
img = Image.open(io.BytesIO(image_data))
# Convert to RGBA if necessary
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Calculate new dimensions maintaining aspect ratio
width, height = img.size
target_width, target_height = target_size
# Calculate scaling factor
scale = min(target_width/width, target_height/height)
new_size = (int(width * scale), int(height * scale))
# Resize image
img = img.resize(new_size, Image.Resampling.LANCZOS)
# Create new image with transparent background
new_img = Image.new('RGBA', target_size, (0, 0, 0, 0))
# Calculate position to paste resized image (centered)
paste_x = (target_width - new_size[0]) // 2
paste_y = (target_height - new_size[1]) // 2
# Paste resized image onto new image
new_img.paste(img, (paste_x, paste_y), img)
# Save to bytes
output = io.BytesIO()
new_img.save(output, format='PNG', optimize=True)
return output.getvalue()
except Exception as e:
print(f"Error resizing image: {str(e)}")
return None
5. Download the Viz Thumbnail Images.
This function downloads each dashboard image from its link. If anything goes wrong (slow internet, broken link), it tries again up to 3 times. After downloading, it resizes the image and saves it to both your project folder and Tableau Shapes folder so you can use them visually in Tableau.
def download_image(url, save_paths, filename, max_retries=3):
# Downloads, resizes, and saves an image.
for attempt in range(max_retries):
try:
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
image_data = response.content
resized_data = resize_image(image_data)
if not resized_data:
raise Exception("Failed to resize image")
for path in save_paths:
with open(path, 'wb') as f:
f.write(resized_data)
print(f"✓ Successfully downloaded and resized: {filename}")
return True
except requests.exceptions.Timeout:
wait_time = (attempt + 1) * 5
print(f"! Timeout downloading {filename}, retrying in {wait_time}s... ({attempt+1}/{max_retries})")
time.sleep(wait_time)
except Exception as e:
print(f"✗ Error processing {filename}: {str(e)}")
return False
As each image is processed, the terminal logs whether it was downloaded and resized successfully, with retry attempts clearly marked if needed.
6. Download Images Concurrently
Instead of downloading one image at a time, this function downloads several at once to save time. It tracks which ones succeed or fail and shows a progress bar while it works, like downloading multiple files at once from Google Drive.
def download_images_concurrently(image_tasks, max_workers=5):
"""Download multiple images concurrently with progress tracking."""
successful_downloads = []
failed_downloads = []
print(f"\nStarting download of {len(image_tasks)} images...")
print("Progress: [", end="")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for url, paths, filename in image_tasks:
futures.append(executor.submit(download_image, url, paths, filename))
# Process completed downloads
for i, future in enumerate(concurrent.futures.as_completed(futures)):
if future.result():
successful_downloads.append(i)
else:
failed_downloads.append(i)
# Update progress bar
progress = (i + 1) / len(futures) * 100
print("=", end="", flush=True)
print("] 100%")
print(f"\nDownload Summary:")
print(f"✓ Successfully downloaded: {len(successful_downloads)} images")
print(f"✗ Failed to download: {len(failed_downloads)} images")
return len(successful_downloads)
7. Saving Data to CSV File
This is where everything comes together. It prepares the data, downloads all images, then saves the dashboard info in a spreadsheet file (CSV) that you can open with Excel or Tableau. It also makes sure the image folders are clean before saving new ones.
Each row includes fields like the author’s name, dashboard title, view count, and more. I also created a field called shapeReference, this assigns a unique code (like 001, 002, etc.) to each image. This makes it easy to use them later as custom shapes in Tableau, especially when visualizing dashboards with shape-based marks.
I also curated the date field manually by assigning each Viz a date that counts backward from today. This ensures the entries are chronologically meaningful, since the Tableau Public API doesn’t include the official nomination date for each Viz of the Day.
def save_votd_to_csv(votds, filename="votd_data.csv"):
"""Save VOTD data to CSV and download images."""
script_dir = os.path.dirname(os.path.abspath(__file__))
file_path = os.path.join(script_dir, filename)
# Set up both image directories
local_images_dir = os.path.join(script_dir, 'votd_images')
tableau_shapes_dir = "/Users/godzilla/Documents/My Tableau Repository/Shapes/votd_images"
# Clear both directories
clear_images_folder(local_images_dir)
clear_images_folder(tableau_shapes_dir)
new_rows = []
today = datetime.now().date()
total_votds = len(votds)
# Prepare image download tasks
image_tasks = []
for idx, viz in enumerate(votds):
date = today - timedelta(days=idx)
# Get image URL and prepare paths
image_url = get_image_url(viz)
image_filename = f"{total_votds - idx:03d}"
local_image_path = os.path.join(local_images_dir, f"{image_filename}.png")
tableau_image_path = os.path.join(tableau_shapes_dir, f"{image_filename}.png")
if image_url:
image_tasks.append((image_url, [local_image_path, tableau_image_path], image_filename))
shape_reference = image_filename
else:
shape_reference = None
# Create row with all viz data
row = {
"date": date.strftime('%Y-%m-%d'),
"authorDisplayName": clean_text(viz.get("authorDisplayName", "")),
"title": clean_text(viz.get("title", "")),
"viewCount": viz.get("viewCount"),
"numberOfFavorites": viz.get("numberOfFavorites"),
"vizLink": get_viz_link(viz),
"shapeReference": shape_reference
}
new_rows.append(row)
# Download all images concurrently
success_count = download_images_concurrently(image_tasks)
# Create DataFrame and save to CSV
df = pd.DataFrame(new_rows)
df.to_csv(file_path, index=False)
print(f"\nSaved {len(df)} VOTDs to {file_path}")
print(f"Downloaded images are saved in:")
print(f"- Local: {local_images_dir}")
print(f"- Tableau Shapes: {tableau_shapes_dir}")
# Display summary
print("\nFirst few rows of the CSV (newest first):")
print(df[['date', 'title', 'authorDisplayName', 'shapeReference']].head())
Once all images are processed and saved, the script summarizes the results and displays the first few rows of the generated CSV file, as shown below.
8. Main Script Runner
This part tells Python to run the script and when executed:
if __name__ == "__main__":
votds = fetch_all_votd(limit=500)
if votds:
save_votd_to_csv(votds)
else:
print("No VOTD data fetched.")
Tableau Consultant | Tableau Visionary 2025 | Tableau Ambassador 4x | Toronto TUG Leader
2moThanks for sharing, Zyad
Tableau Ambassador || Tableau Featured Author 2024 || 4x #VOTD || Tableau Most Notable Newbie Winner || Tableau Community Highlight || Business Inteligence Analyst || Data Visualization Developer
2moLove this, Zyad
Certified Tableau Desktop Specialist | Alteryx Certified | SQL, Power BI, Tableau Prep, Machine Learning, Excel, Pyspark, GenAI, Databricks, Data Scientist, Data Engineer, Python
2moThanks for sharing, Zyad. Can this be used for Months related like BHM WHM etc?
Tableau Visionary | Thought Leader in Data Visualization
2moNicely put, Zyad! This helps a lot and I am going to use the process to gather the information. Thank you so much for sharing your process with us!