How to use the Tableau Public API

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.

Article content
https://public.tableau.com/app/profile/zyad.wael/viz/VizoftheDayNominations/Dashboard

This script does three main things:

  1. Automatically collects and organizes Tableau dashboards (called “Viz of the Day”).
  2. Visits Tableau Public and collects up to 500 dashboards.
  3. Downloads a small image (thumbnail) for each one and makes sure they all have the same size.
  4. Saves everything into a spreadsheet file, which you can open in Excel or Tableau.

Table of Content

  1. Setup and Imports
  2. Fetching the VOTD Data
  3. Cleaning up the text
  4. Image Extraction Utilities
  5. Download the Viz Thumbnail Images
  6. Download Images Concurrently
  7. Saving Data to CSV File
  8. Main Script Runner

Glossary

  1. API: Stands for Application Programming Interface. It's a way for code to "talk to" websites and ask for data (like dashboards, images, or info).
  2. Python: A popular programming language that’s used to automate tasks like downloading files, cleaning data, or building dashboards.
  3. JSON: A way computers format and share data online. Looks like a big list or dictionary of information.
  4. Script: A small program written in Python that runs step-by-step to do a task, like downloading images or saving data.
  5. Function: A reusable block of code that does one specific job, like downloading an image or cleaning up text. Think of it like a machine with one purpose.
  6. Concurrent Downloads: Downloading multiple files at the same time instead of waiting for one to finish before starting another, faster and more efficient.
  7. Thread / Multithreading: A way to do several things at once in a script. Used here to speed up image downloads.


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:

  • requests for making API calls
  • pandas for creating and saving the dataset
  • PIL (Pillow) for image processing
  • concurrent.futures for parallel downloads

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        

  • requests – for making API calls to Tableau
  • pandas – for creating and saving the dataset
  • pillow (PIL) – for image processing (resizing and formatting)
  • concurrent.futures – included in Python 3 by default (used for parallel downloads)
  • datetime, time, os, io, html, json, glob – all are built-in standard libraries

1.2. Libraries

pip install requests pandas pillow        

  • Make sure you have Python 3.6+ installed. You can check your version with:

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.

Article content

3. Cleaning Up Text

Sometimes the text we get from the internet includes weird codes like &amp; 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('&amp;', '&')
    text = text.replace('&#039;', "'")
    text = text.replace('&quot;', '"')
    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.

Article content

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.

Article content

8. Main Script Runner

This part tells Python to run the script and when executed:

  • It pulls 500 VOTDs
  • Downloads the images
  • Saves the metadata to votd_data.csv

if __name__ == "__main__":
    votds = fetch_all_votd(limit=500)
    if votds:
        save_votd_to_csv(votds)
    else:
        print("No VOTD data fetched.")
        


Adrian Zinovei

Tableau Consultant | Tableau Visionary 2025 | Tableau Ambassador 4x | Toronto TUG Leader

2mo

Thanks for sharing, Zyad

Like
Reply
Maureen Okonkwo

Tableau Ambassador || Tableau Featured Author 2024 || 4x #VOTD || Tableau Most Notable Newbie Winner || Tableau Community Highlight || Business Inteligence Analyst || Data Visualization Developer

2mo

Love this, Zyad

Vignesh Suresh

Certified Tableau Desktop Specialist | Alteryx Certified | SQL, Power BI, Tableau Prep, Machine Learning, Excel, Pyspark, GenAI, Databricks, Data Scientist, Data Engineer, Python

2mo

Thanks for sharing, Zyad. Can this be used for Months related like BHM WHM etc?

Like
Reply
Prasann Prem

Tableau Visionary | Thought Leader in Data Visualization

2mo

Nicely 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!

To view or add a comment, sign in

Others also viewed

Explore topics