Tarot Data Explorer: filtering a 78-row open dataset with Streamlit

Hi everyone,

I made a small Streamlit data explorer for searching and comparing a structured 78-card dataset.

The source is the DeckAura open tarot meanings dataset. It includes card names, arcana, suits, elements, upright and reversed meanings, plus separate love and career fields.

This was a useful example for testing three common Streamlit patterns:

  • Caching a remote CSV
  • Filtering multiple text columns with Pandas
  • Combining a table view with a record detail panel

The same structure could be reused for catalog, glossary, research or annotation apps.

App code

import pandas as pd
import streamlit as st

DATA_URL = (
    "https://huggingface.co/datasets/Blacik/"
    "deckaura-tarot-card-meanings/resolve/main/"
    "tarot_card_meanings.csv"
)

st.set_page_config(
    page_title="Tarot Data Explorer",
    layout="wide",
)


@st.cache_data(ttl="24h")
def load_data(url: str) -> pd.DataFrame:
    data = pd.read_csv(url)

    text_columns = data.select_dtypes(include="object").columns
    data[text_columns] = data[text_columns].fillna("")

    return data


df = load_data(DATA_URL)

st.title("Tarot Data Explorer")
st.caption("Search and compare 78 structured card records.")

with st.sidebar:
    st.header("Filters")

    query = st.text_input(
        "Search",
        placeholder="Try transformation, balance or career",
    )

    arcana_options = sorted(
        value for value in df["arcana"].unique() if value
    )
    selected_arcana = st.multiselect(
        "Arcana",
        arcana_options,
    )

    suit_options = sorted(
        value for value in df["suit"].unique() if value
    )
    selected_suits = st.multiselect(
        "Suit",
        suit_options,
    )

filtered = df.copy()

if selected_arcana:
    filtered = filtered[
        filtered["arcana"].isin(selected_arcana)
    ]

if selected_suits:
    filtered = filtered[
        filtered["suit"].isin(selected_suits)
    ]

if query:
    search_columns = [
        "card_name",
        "upright_meaning",
        "reversed_meaning",
        "love_meaning",
        "career_meaning",
    ]

    matches = filtered[search_columns].apply(
        lambda column: column.str.contains(
            query,
            case=False,
            regex=False,
            na=False,
        )
    )

    filtered = filtered[matches.any(axis=1)]

metric_1, metric_2, metric_3 = st.columns(3)

metric_1.metric("Visible records", len(filtered))
metric_2.metric(
    "Major Arcana",
    int((filtered["arcana"] == "Major Arcana").sum()),
)
metric_3.metric(
    "Elements",
    filtered.loc[
        filtered["element"] != "", "element"
    ].nunique(),
)

table_columns = [
    "card_number",
    "card_name",
    "arcana",
    "suit",
    "element",
    "yes_or_no",
]

st.dataframe(
    filtered[table_columns],
    hide_index=True,
    use_container_width=True,
)

if filtered.empty:
    st.info("No records match the current filters.")
else:
    st.subheader("Inspect a record")

    selected_name = st.selectbox(
        "Card",
        filtered["card_name"].tolist(),
    )

    card = filtered.loc[
        filtered["card_name"] == selected_name
    ].iloc[0]

    left, right = st.columns(2)

    with left:
        st.markdown("#### Upright meaning")
        st.write(card["upright_meaning"])

        st.markdown("#### Love")
        st.write(card["love_meaning"])

    with right:
        st.markdown("#### Reversed meaning")
        st.write(card["reversed_meaning"])

        st.markdown("#### Career")
        st.write(card["career_meaning"])

    if card["guide_url"]:
        st.link_button(
            "Open the complete card guide",
            card["guide_url"],
        )

The minimal requirements.txt is:

streamlit
pandas

The remote CSV is cached for 24 hours with st.cache_data, so widget reruns do not download the dataset repeatedly. The search also uses regex=False, which prevents characters such as [ or * from being interpreted as regular expressions.

I am considering two improvements:

  1. Using DataFrame row selection to update the detail panel instead of a separate selectbox.
  2. Adding a comparison mode where two or three records can be viewed side by side.

For this type of explorer, would you keep the separate record selector or use st.dataframe selection as the main interaction?